Account Dashboard Layout
Create a comprehensive account dashboard with React that displays customer information, navigation, and summary cards. Learn layout patterns for authenticated pages.
After customers log in, they need a clear overview of their account. Let’s build a dashboard that displays key information at a glance—profile details, recent orders, and quick navigation to manage their account.
Designing Information Hierarchy
A good dashboard answers the customer’s most common questions immediately:
- Who am I? Profile info confirms they’re logged into the right account
- What have I ordered? Order count and recent activity show purchase history
- Where can I go? Navigation makes all account features discoverable
The key is progressive disclosure—show summary information on the dashboard, with links to detailed views. Customers scanning their account shouldn’t be overwhelmed with data, but should easily find paths to what they need.
Theme Integration
The dashboard integrates with Shopify’s customer account template. Unlike product pages that might have multiple React widgets, the account dashboard typically takes over the entire content area:
templates/customers/account.liquid└── sections/customer-account.liquid └── <div id="account-dashboard-root"> └── AccountDashboard (React) ├── DashboardHeader ├── AccountNav └── DashboardContent ├── CustomerInfoCard ├── DashboardSummaryCard (orders) ├── DashboardSummaryCard (addresses) └── RecentOrdersPreviewThis full-page approach makes sense for authenticated pages. Unlike a product page where React might only handle the add-to-cart button, the account area benefits from React managing the entire experience—consistent navigation, unified styling, and potential for client-side transitions between account sections.
Data Requirements
Before building components, let’s understand what customer data Shopify provides. This shapes our component interfaces:
| Prop | Source | Liquid Field |
|---|---|---|
customer.email | JSON script element | customer.email |
customer.firstName | JSON script element | customer.first_name |
customer.lastName | JSON script element | customer.last_name |
customer.ordersCount | JSON script element | customer.orders_count |
customer.totalSpent | JSON script element | `customer.total_spent |
customer.defaultAddress | JSON script element | customer.default_address |
recentOrders | JSON script element | customer.orders limit: 3 |
logoutUrl | JSON script element | routes.account_logout_url |
Notice we’re getting pre-formatted values like totalSpent using Liquid’s money filter. This keeps currency formatting consistent with the rest of the store and avoids JavaScript locale complexity.
Dashboard Architecture
The visual layout uses a classic sidebar navigation pattern. This works well for account pages because customers often move between sections (orders, addresses, settings) and need persistent navigation:
┌──────────────────────────────────────────────────────────────────────────────┐│ ACCOUNT DASHBOARD ││ ││ ┌─────────────────────────────────────────────────────────────────────────┐ ││ │ DashboardHeader │ ││ │ Welcome back, John! [Log Out] │ ││ └─────────────────────────────────────────────────────────────────────────┘ ││ ││ ┌────────────────────┐ ┌──────────────────────────────────────────────────┐││ │ AccountNav │ │ DashboardContent │││ │ │ │ │││ │ ┌──────────────┐ │ │ ┌────────────────────────────────────────────┐ │││ │ │ Overview │ │ │ │ CustomerInfoCard │ │││ │ │ (active) │ │ │ │ │ │││ │ ├──────────────┤ │ │ │ John Doe │ │││ │ │ Orders │ │ │ │ [email protected] │ │││ │ ├──────────────┤ │ │ │ Member since January 2024 │ │││ │ │ Addresses │ │ │ │ [Edit] │ │││ │ └──────────────┘ │ │ └────────────────────────────────────────────┘ │││ │ │ │ │││ │ │ │ ┌───────────────────┐ ┌───────────────────┐ │││ │ │ │ │ DashboardSummary │ │ DashboardSummary │ │││ │ │ │ │ │ │ │ │││ │ │ │ │ Orders │ │ Addresses │ │││ │ │ │ │ 5 orders │ │ 2 saved │ │││ │ │ │ │ $1,234 spent │ │ │ │││ │ │ │ │ │ │ │ │││ │ │ │ │ [View Orders →] │ │ [Manage →] │ │││ │ │ │ └───────────────────┘ └───────────────────┘ │││ │ │ │ │││ │ │ │ ┌────────────────────────────────────────────┐ │││ │ │ │ │ RecentOrdersPreview │ │││ │ │ │ │ │ │││ │ │ │ │ Recent Orders │ │││ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │││ │ │ │ │ │ #1001 │ │ #1002 │ │ #1003 │ │ │││ │ │ │ │ │ Jan 5 │ │ Dec 20 │ │ Dec 1 │ │ │││ │ │ │ │ │ $125.00 │ │ $89.00 │ │ $234.00 │ │ │││ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │││ │ │ │ │ │ │││ │ │ │ │ [View All Orders] │ │││ │ │ │ └────────────────────────────────────────────┘ │││ └────────────────────┘ └──────────────────────────────────────────────────┘│└──────────────────────────────────────────────────────────────────────────────┘On mobile, the sidebar collapses to a horizontal scrolling navigation bar. This maintains the navigation’s presence without consuming vertical space that should show the customer’s actual content.
TypeScript Interfaces
Our data interfaces mirror the Shopify customer object structure but use camelCase for JavaScript conventions. We also create a DashboardData wrapper type that includes everything needed to render the page:
interface Customer { id: number; email: string; firstName: string; lastName: string; name: string; phone: string | null; ordersCount: number; totalSpent: string; // Pre-formatted with currency symbol acceptsMarketing: boolean; defaultAddress: Address | null; tags: string[]; createdAt: string;}
interface Address { id: number; firstName: string; lastName: string; company: string | null; address1: string; address2: string | null; city: string; province: string; provinceCode: string; country: string; countryCode: string; zip: string; phone: string | null;}
interface OrderPreview { id: number; name: string; // e.g., "#1001" createdAt: string; totalPrice: string; // Pre-formatted financialStatus: string; fulfillmentStatus: string | null; itemCount: number; customerUrl: string;}
/** * Complete data needed to render the dashboard. * Passed from Liquid via JSON script tag. */interface DashboardData { customer: Customer; recentOrders: OrderPreview[]; addressCount: number; logoutUrl: string; ordersUrl: string; addressesUrl: string;}Note how OrderPreview is a lighter type than a full order object. For the dashboard, we only need enough data to show order cards—the full order details live on separate pages.
The Container Component
The AccountDashboard component orchestrates the layout. It receives all data through props and distributes pieces to child components. This “container” pattern keeps child components focused and reusable:
import { DashboardHeader } from './DashboardHeader';import { AccountNav } from './AccountNav';import { CustomerInfoCard } from './CustomerInfoCard';import { DashboardSummaryCard } from './DashboardSummaryCard';import { RecentOrdersPreview } from './RecentOrdersPreview';import styles from './AccountDashboard.module.css';
interface AccountDashboardProps { data: DashboardData;}
export function AccountDashboard({ data }: AccountDashboardProps) { const { customer, recentOrders, addressCount, logoutUrl, ordersUrl, addressesUrl } = data;
return ( <div className={styles.dashboard}> {/* Welcome message and logout at the top for quick access */} <DashboardHeader customerName={customer.firstName} logoutUrl={logoutUrl} />
<div className={styles.layout}> {/* Sidebar stays visible as user scrolls */} <aside className={styles.sidebar}> <AccountNav currentPage="overview" /> </aside>
{/* Main content scrolls independently */} <main className={styles.content}> <CustomerInfoCard customer={customer} />
{/* Summary cards in a responsive grid */} <div className={styles.summaryGrid}> <DashboardSummaryCard title="Orders" icon={<OrdersIcon />} stats={[ { label: 'Total orders', value: customer.ordersCount.toString() }, { label: 'Total spent', value: customer.totalSpent }, ]} linkText="View all orders" linkUrl={ordersUrl} />
<DashboardSummaryCard title="Addresses" icon={<AddressIcon />} stats={[ { label: 'Saved addresses', value: addressCount.toString() }, ]} linkText="Manage addresses" linkUrl={addressesUrl} /> </div>
{/* Only show recent orders if the customer has any */} {recentOrders.length > 0 && ( <RecentOrdersPreview orders={recentOrders} viewAllUrl={ordersUrl} /> )} </main> </div> </div> );}The conditional rendering for RecentOrdersPreview handles new customers who haven’t placed orders yet. Rather than showing an empty section, we simply don’t render it—the summary card already indicates “0 orders.”
Dashboard Layout Styles
The CSS uses Grid for the main layout with a fixed-width sidebar. The sticky positioning keeps the navigation visible while the main content scrolls:
.dashboard { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem;}
.layout { display: grid; grid-template-columns: 220px 1fr; gap: 2rem; margin-top: 2rem;}
.sidebar { position: sticky; top: 2rem; align-self: start; /* Prevents sidebar from stretching to content height */}
.content { min-width: 0; /* Prevents grid blowout from long content */}
.summaryGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;}
/* Tablet: Stack sidebar above content */@media (max-width: 900px) { .layout { grid-template-columns: 1fr; }
.sidebar { position: static; /* Nav scrolls with content on mobile */ }}
/* Mobile: Tighter spacing */@media (max-width: 600px) { .dashboard { padding: 1rem; }
.summaryGrid { grid-template-columns: 1fr; }}The min-width: 0 on .content is a common Grid fix. Without it, long content (like order IDs) can force the grid column wider than intended.
Header with Logout
The header serves two purposes: greet the customer by name (confirming their identity) and provide quick logout access. Logout doesn’t need to be prominent, but it must be findable:
import styles from './DashboardHeader.module.css';
interface DashboardHeaderProps { customerName: string; logoutUrl: string;}
export function DashboardHeader({ customerName, logoutUrl }: DashboardHeaderProps) { return ( <header className={styles.header}> <h1 className={styles.title}> Welcome back, {customerName}! </h1> <a href={logoutUrl} className={styles.logoutLink}> Log out </a> </header> );}We use a standard anchor tag for logout rather than a button. This works because Shopify’s logout URL handles the POST request server-side (often via a form submission or redirect). The styling keeps it subtle but accessible:
.header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 1.5rem; border-bottom: 1px solid var(--color-border);}
.title { margin: 0; font-size: 1.75rem; font-weight: 600;}
.logoutLink { color: var(--color-text-muted); font-size: 0.875rem; text-decoration: none;}
.logoutLink:hover { color: var(--color-text); text-decoration: underline;}
@media (max-width: 600px) { .header { flex-direction: column; align-items: flex-start; gap: 0.75rem; }}Account Navigation
Navigation consistency across account pages helps customers build a mental model of where things are. The AccountNav component highlights the current page and uses proper ARIA attributes:
import styles from './AccountNav.module.css';
interface AccountNavProps { currentPage: 'overview' | 'orders' | 'addresses';}
const navItems = [ { id: 'overview', label: 'Overview', url: '/account' }, { id: 'orders', label: 'Orders', url: '/account/orders' }, { id: 'addresses', label: 'Addresses', url: '/account/addresses' },];
export function AccountNav({ currentPage }: AccountNavProps) { return ( <nav className={styles.nav} aria-label="Account navigation"> <ul className={styles.list}> {navItems.map((item) => ( <li key={item.id}> <a href={item.url} className={`${styles.link} ${currentPage === item.id ? styles.active : ''}`} aria-current={currentPage === item.id ? 'page' : undefined} > {item.label} </a> </li> ))} </ul> </nav> );}The aria-current="page" attribute tells screen readers which page the user is on. This is especially important for navigation that appears on every page—visual styling alone isn’t enough for accessibility.
Customer Info Card
The profile card shows identity information at the top of the dashboard. The avatar uses initials as a fallback since Shopify doesn’t store customer profile images by default:
import styles from './CustomerInfoCard.module.css';
interface CustomerInfoCardProps { customer: Customer;}
export function CustomerInfoCard({ customer }: CustomerInfoCardProps) { const joinDate = new Date(customer.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric', });
return ( <div className={styles.card}> {/* Initials avatar when no profile image exists */} <div className={styles.avatar}> {customer.firstName.charAt(0)} {customer.lastName.charAt(0)} </div>
<div className={styles.info}> <h2 className={styles.name}>{customer.name}</h2> <p className={styles.email}>{customer.email}</p> <p className={styles.memberSince}>Member since {joinDate}</p> </div>
{/* Show default address if one exists */} {customer.defaultAddress && ( <div className={styles.address}> <h3 className={styles.addressTitle}>Default Address</h3> <address className={styles.addressText}> {customer.defaultAddress.address1} {customer.defaultAddress.address2 && <>, {customer.defaultAddress.address2}</>} <br /> {customer.defaultAddress.city}, {customer.defaultAddress.provinceCode} {customer.defaultAddress.zip} <br /> {customer.defaultAddress.country} </address> </div> )} </div> );}Using the semantic <address> element for the address content is appropriate here since it contains contact information. The card uses a three-column grid that collapses gracefully on mobile.
Reusable Summary Cards
Summary cards follow a consistent pattern: icon, title, stats, and action link. Making this a reusable component ensures visual consistency and reduces code duplication:
import styles from './DashboardSummaryCard.module.css';
interface Stat { label: string; value: string;}
interface DashboardSummaryCardProps { title: string; icon: React.ReactNode; stats: Stat[]; linkText: string; linkUrl: string;}
export function DashboardSummaryCard({ title, icon, stats, linkText, linkUrl,}: DashboardSummaryCardProps) { return ( <div className={styles.card}> <div className={styles.header}> <span className={styles.icon}>{icon}</span> <h3 className={styles.title}>{title}</h3> </div>
{/* Description list is semantically appropriate for label/value pairs */} <dl className={styles.stats}> {stats.map((stat) => ( <div key={stat.label} className={styles.stat}> <dt className={styles.statLabel}>{stat.label}</dt> <dd className={styles.statValue}>{stat.value}</dd> </div> ))} </dl>
<a href={linkUrl} className={styles.link}> {linkText} <ArrowIcon /> </a> </div> );}The <dl> (description list) element is semantically correct for label-value pairs like “Total orders: 5”. This helps screen readers convey the relationship between the label and its value.
Recent Orders Preview
The orders preview gives customers quick access to their latest activity. Each order card is a link to the full order details:
import styles from './RecentOrdersPreview.module.css';
interface RecentOrdersPreviewProps { orders: OrderPreview[]; viewAllUrl: string;}
export function RecentOrdersPreview({ orders, viewAllUrl }: RecentOrdersPreviewProps) { return ( <section className={styles.section}> <div className={styles.header}> <h2 className={styles.title}>Recent Orders</h2> <a href={viewAllUrl} className={styles.viewAll}> View all orders </a> </div>
<div className={styles.orders}> {orders.map((order) => ( <OrderPreviewCard key={order.id} order={order} /> ))} </div> </section> );}
function OrderPreviewCard({ order }: { order: OrderPreview }) { const orderDate = new Date(order.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', });
return ( <a href={order.customerUrl} className={styles.orderCard}> <div className={styles.orderHeader}> <span className={styles.orderName}>{order.name}</span> <span className={styles.orderDate}>{orderDate}</span> </div>
<div className={styles.orderDetails}> <span className={styles.orderTotal}>{order.totalPrice}</span> <span className={styles.orderItems}> {order.itemCount} {order.itemCount === 1 ? 'item' : 'items'} </span> </div>
<div className={styles.orderStatus}> <StatusBadge status={order.financialStatus} type="financial" /> {order.fulfillmentStatus && ( <StatusBadge status={order.fulfillmentStatus} type="fulfillment" /> )} </div> </a> );}Status Badge Patterns
Order status badges use color coding to convey meaning at a glance. Green means complete, yellow means in progress, and red indicates problems:
function StatusBadge({ status, type }: { status: string; type: 'financial' | 'fulfillment' }) { const statusClasses: Record<string, string> = { paid: styles.statusSuccess, fulfilled: styles.statusSuccess, pending: styles.statusWarning, partial: styles.statusWarning, unfulfilled: styles.statusDefault, refunded: styles.statusError, voided: styles.statusError, };
const className = statusClasses[status.toLowerCase()] || styles.statusDefault; const label = status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ');
return ( <span className={`${styles.badge} ${className}`}> {label} </span> );}Note that we don’t rely on color alone—the text label (“Paid”, “Fulfilled”, etc.) conveys the status for users who can’t perceive color differences.
Liquid Data Bridge
The Liquid template collects all customer data into a JSON object. This runs server-side, ensuring the data is available immediately when React mounts:
{% comment %} sections/customer-account.liquid {% endcomment %}
<div id="account-dashboard-root"></div>
<script type="application/json" id="dashboard-data"> { "customer": { "id": {{ customer.id }}, "email": {{ customer.email | json }}, "firstName": {{ customer.first_name | json }}, "lastName": {{ customer.last_name | json }}, "name": {{ customer.name | json }}, "phone": {{ customer.phone | default: "null" | json }}, "ordersCount": {{ customer.orders_count }}, "totalSpent": {{ customer.total_spent | money | json }}, "acceptsMarketing": {{ customer.accepts_marketing }}, "defaultAddress": {% if customer.default_address %} { "id": {{ customer.default_address.id }}, "firstName": {{ customer.default_address.first_name | json }}, "lastName": {{ customer.default_address.last_name | json }}, "company": {{ customer.default_address.company | default: "null" | json }}, "address1": {{ customer.default_address.address1 | json }}, "address2": {{ customer.default_address.address2 | default: "null" | json }}, "city": {{ customer.default_address.city | json }}, "province": {{ customer.default_address.province | json }}, "provinceCode": {{ customer.default_address.province_code | json }}, "country": {{ customer.default_address.country | json }}, "countryCode": {{ customer.default_address.country_code | json }}, "zip": {{ customer.default_address.zip | json }}, "phone": {{ customer.default_address.phone | default: "null" | json }} } {% else %}null{% endif %}, "tags": {{ customer.tags | json }}, "createdAt": {{ customer.created_at | date: "%Y-%m-%dT%H:%M:%S" | json }} }, "recentOrders": [ {%- for order in customer.orders limit: 3 -%} { "id": {{ order.id }}, "name": {{ order.name | json }}, "createdAt": {{ order.created_at | date: "%Y-%m-%dT%H:%M:%S" | json }}, "totalPrice": {{ order.total_price | money | json }}, "financialStatus": {{ order.financial_status | json }}, "fulfillmentStatus": {{ order.fulfillment_status | default: "unfulfilled" | json }}, "itemCount": {{ order.item_count }}, "customerUrl": {{ order.customer_url | json }} }{% unless forloop.last %},{% endunless %} {%- endfor -%} ], "addressCount": {{ customer.addresses.size }}, "logoutUrl": {{ routes.account_logout_url | json }}, "ordersUrl": "/account/orders", "addressesUrl": "/account/addresses" }</script>
{% schema %}{ "name": "Account Dashboard", "settings": []}{% endschema %}The limit: 3 on the orders loop is intentional—we only want a preview, not the full history. Loading fewer orders means faster page loads.
Mounting the Dashboard
The entry point script finds the root element and data, then renders React:
import { createRoot } from 'react-dom/client';import { AccountDashboard } from '@/components/customer/AccountDashboard';
export function mountAccountDashboard() { const root = document.getElementById('account-dashboard-root'); const dataScript = document.getElementById('dashboard-data');
if (!root || !dataScript) return;
try { const data = JSON.parse(dataScript.textContent || '{}'); createRoot(root).render(<AccountDashboard data={data} />); } catch (error) { console.error('Failed to mount account dashboard:', error); }}
// Auto-mount when DOM is readyif (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountAccountDashboard);} else { mountAccountDashboard();}The document.readyState check handles both cases: if the script loads before DOMContentLoaded, we wait; if it loads after, we mount immediately.
Key Takeaways
- Information hierarchy matters: Show summary data on the dashboard with links to details—don’t overwhelm users with everything at once
- Sidebar navigation: Works well for multi-page account areas; use sticky positioning on desktop and horizontal scroll on mobile
- Data shaping: Transform Shopify’s snake_case to JavaScript camelCase, and let Liquid handle currency formatting
- Semantic HTML: Use
<address>for addresses,<dl>for label-value pairs, andaria-currentfor navigation - Progressive enhancement: The page works without JavaScript (Liquid can render static content); React enhances the experience
- Conditional rendering: Don’t show empty sections—check for data before rendering components like RecentOrdersPreview
In the next lesson, we’ll build comprehensive order history and order detail views.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...