Skip to main content

Overview

This example shows how to build a custom analytics dashboard that fetches data from the Grain Query API and displays it in a beautiful, interactive interface. We’ll use React, Next.js, and Chart.js to create a dashboard that rivals the built-in Grain dashboard.
This example assumes you have a Builder plan or higher and have created an API key with Query API permissions.

Project Setup

1. Create Next.js Project

npx create-next-app@latest grain-dashboard --typescript --tailwind --eslint
cd grain-dashboard

2. Install Dependencies

npm install chart.js react-chartjs-2 date-fns
npm install -D @types/chart.js

3. Environment Variables

Create .env.local:
GRAIN_TENANT_ID=your-tenant-id
GRAIN_API_KEY=your-secret-key
NEXT_PUBLIC_GRAIN_TENANT_ID=your-tenant-id

Core API Client

First, let’s create a client for interacting with the Query API:
// lib/grain-api.ts
interface QueryRequest {
  event?: string;
  after?: string;
  before?: string;
  filterSet?: Array<{
    property: string;
    comparison: string;
    value: any;
  }>;
  pagination?: {
    offset: number;
    size: number;
  };
}

interface Event {
  eventName: string;
  userId: string;
  eventTs: string;
  properties: Record<string, any>;
  eventDate: string;
  insertId: string;
}

interface CountResponse {
  count: number;
}

class GrainAPI {
  private baseUrl = 'https://api.grainql.com/v1/api/query';
  private apiKey: string;
  private tenantId: string;

  constructor(apiKey: string, tenantId: string) {
    this.apiKey = apiKey;
    this.tenantId = tenantId;
  }

  async queryEvents(request: QueryRequest): Promise<Event[]> {
    const response = await fetch(`${this.baseUrl}/${this.tenantId}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': this.apiKey,
      },
      body: JSON.stringify(request),
    });

    if (!response.ok) {
      throw new Error(`Query failed: ${response.status} ${response.statusText}`);
    }

    return response.json();
  }

  async countEvents(request: Omit<QueryRequest, 'pagination'>): Promise<number> {
    const response = await fetch(`${this.baseUrl}/count/${this.tenantId}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': this.apiKey,
      },
      body: JSON.stringify(request),
    });

    if (!response.ok) {
      throw new Error(`Count query failed: ${response.status} ${response.statusText}`);
    }

    const result: CountResponse = await response.json();
    return result.count;
  }

  async getEventNames(): Promise<string[]> {
    const response = await fetch(`${this.baseUrl}/events/${this.tenantId}`, {
      headers: {
        'X-API-Key': this.apiKey,
      },
    });

    if (!response.ok) {
      throw new Error(`Failed to get events: ${response.status} ${response.statusText}`);
    }

    return response.json();
  }
}

export const grainAPI = new GrainAPI(
  process.env.GRAIN_API_KEY!,
  process.env.GRAIN_TENANT_ID!
);

Dashboard Components

1. Metric Card Component

// components/MetricCard.tsx
interface MetricCardProps {
  title: string;
  value: number | string;
  change?: number;
  icon?: React.ReactNode;
  loading?: boolean;
}

export function MetricCard({ title, value, change, icon, loading }: MetricCardProps) {
  if (loading) {
    return (
      <div className="bg-white rounded-lg shadow p-6 animate-pulse">
        <div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
        <div className="h-8 bg-gray-200 rounded w-1/3"></div>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-sm font-medium text-gray-600">{title}</p>
          <p className="text-2xl font-bold text-gray-900">{value.toLocaleString()}</p>
          {change !== undefined && (
            <p className={`text-sm ${change >= 0 ? 'text-green-600' : 'text-red-600'}`}>
              {change >= 0 ? '+' : ''}{change.toFixed(1)}% from last period
            </p>
          )}
        </div>
        {icon && <div className="text-gray-400">{icon}</div>}
      </div>
    </div>
  );
}

2. Event Timeline Chart

// components/EventTimelineChart.tsx
import { Line } from 'react-chartjs-2';
import { format, subDays } from 'date-fns';

interface EventTimelineChartProps {
  events: Event[];
  loading?: boolean;
}

export function EventTimelineChart({ events, loading }: EventTimelineChartProps) {
  if (loading) {
    return (
      <div className="bg-white rounded-lg shadow p-6 animate-pulse">
        <div className="h-64 bg-gray-200 rounded"></div>
      </div>
    );
  }

  // Group events by date
  const eventsByDate = events.reduce((acc, event) => {
    const date = event.eventDate;
    acc[date] = (acc[date] || 0) + 1;
    return acc;
  }, {} as Record<string, number>);

  // Generate last 30 days
  const last30Days = Array.from({ length: 30 }, (_, i) => {
    const date = subDays(new Date(), 29 - i);
    return format(date, 'yyyy-MM-dd');
  });

  const data = {
    labels: last30Days.map(date => format(new Date(date), 'MMM dd')),
    datasets: [
      {
        label: 'Events',
        data: last30Days.map(date => eventsByDate[date] || 0),
        borderColor: 'rgb(99, 102, 241)',
        backgroundColor: 'rgba(99, 102, 241, 0.1)',
        tension: 0.1,
      },
    ],
  };

  const options = {
    responsive: true,
    plugins: {
      legend: {
        display: false,
      },
    },
    scales: {
      y: {
        beginAtZero: true,
      },
    },
  };

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <h3 className="text-lg font-medium text-gray-900 mb-4">Event Timeline</h3>
      <Line data={data} options={options} />
    </div>
  );
}

3. Top Events Table

// components/TopEventsTable.tsx
interface TopEventsTableProps {
  events: Event[];
  loading?: boolean;
}

export function TopEventsTable({ events, loading }: TopEventsTableProps) {
  if (loading) {
    return (
      <div className="bg-white rounded-lg shadow p-6 animate-pulse">
        <div className="space-y-3">
          {Array.from({ length: 5 }).map((_, i) => (
            <div key={i} className="h-4 bg-gray-200 rounded"></div>
          ))}
        </div>
      </div>
    );
  }

  // Count events by name
  const eventCounts = events.reduce((acc, event) => {
    acc[event.eventName] = (acc[event.eventName] || 0) + 1;
    return acc;
  }, {} as Record<string, number>);

  const topEvents = Object.entries(eventCounts)
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10);

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <h3 className="text-lg font-medium text-gray-900 mb-4">Top Events</h3>
      <div className="space-y-3">
        {topEvents.map(([eventName, count]) => (
          <div key={eventName} className="flex justify-between items-center">
            <span className="text-sm font-medium text-gray-900">{eventName}</span>
            <span className="text-sm text-gray-600">{count.toLocaleString()}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

4. User Activity Chart

// components/UserActivityChart.tsx
import { Doughnut } from 'react-chartjs-2';

interface UserActivityChartProps {
  events: Event[];
  loading?: boolean;
}

export function UserActivityChart({ events, loading }: UserActivityChartProps) {
  if (loading) {
    return (
      <div className="bg-white rounded-lg shadow p-6 animate-pulse">
        <div className="h-64 bg-gray-200 rounded-full"></div>
      </div>
    );
  }

  // Count unique users
  const uniqueUsers = new Set(events.map(event => event.userId)).size;
  const totalEvents = events.length;
  const avgEventsPerUser = totalEvents / uniqueUsers;

  const data = {
    labels: ['Unique Users', 'Total Events'],
    datasets: [
      {
        data: [uniqueUsers, totalEvents],
        backgroundColor: ['rgb(99, 102, 241)', 'rgb(34, 197, 94)'],
        borderWidth: 0,
      },
    ],
  };

  const options = {
    responsive: true,
    plugins: {
      legend: {
        position: 'bottom' as const,
      },
    },
  };

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <h3 className="text-lg font-medium text-gray-900 mb-4">User Activity</h3>
      <div className="flex items-center justify-center mb-4">
        <Doughnut data={data} options={options} />
      </div>
      <div className="text-center">
        <p className="text-sm text-gray-600">
          Average {avgEventsPerUser.toFixed(1)} events per user
        </p>
      </div>
    </div>
  );
}

Main Dashboard Page

// pages/dashboard.tsx
import { useState, useEffect } from 'react';
import { grainAPI } from '../lib/grain-api';
import { MetricCard } from '../components/MetricCard';
import { EventTimelineChart } from '../components/EventTimelineChart';
import { TopEventsTable } from '../components/TopEventsTable';
import { UserActivityChart } from '../components/UserActivityChart';
import { format, subDays } from 'date-fns';

interface DashboardData {
  totalEvents: number;
  uniqueUsers: number;
  topEvents: string[];
  recentEvents: Event[];
}

export default function Dashboard() {
  const [data, setData] = useState<DashboardData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [dateRange, setDateRange] = useState({
    after: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
    before: format(new Date(), 'yyyy-MM-dd'),
  });

  const loadDashboardData = async () => {
    try {
      setLoading(true);
      setError(null);

      const [totalEvents, uniqueUsers, recentEvents] = await Promise.all([
        // Total events count
        grainAPI.countEvents({
          after: dateRange.after,
          before: dateRange.before,
        }),

        // Unique users count
        grainAPI.countEvents({
          after: dateRange.after,
          before: dateRange.before,
          filterSet: [
            { property: 'userId', comparison: 'IS_NOT_NULL', value: null }
          ],
        }),

        // Recent events for charts
        grainAPI.queryEvents({
          after: dateRange.after,
          before: dateRange.before,
          pagination: { offset: 0, size: 1000 },
        }),
      ]);

      setData({
        totalEvents,
        uniqueUsers,
        topEvents: [],
        recentEvents,
      });
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load dashboard data');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    loadDashboardData();
  }, [dateRange]);

  if (error) {
    return (
      <div className="min-h-screen bg-gray-50 flex items-center justify-center">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-gray-900 mb-4">Error</h1>
          <p className="text-gray-600 mb-4">{error}</p>
          <button
            onClick={loadDashboardData}
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
          >
            Retry
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        {/* Header */}
        <div className="mb-8">
          <h1 className="text-3xl font-bold text-gray-900">Analytics Dashboard</h1>
          <p className="text-gray-600 mt-2">
            Custom dashboard powered by Grain Query API
          </p>
        </div>

        {/* Date Range Selector */}
        <div className="mb-8">
          <div className="flex items-center space-x-4">
            <label className="text-sm font-medium text-gray-700">Date Range:</label>
            <input
              type="date"
              value={dateRange.after}
              onChange={(e) => setDateRange(prev => ({ ...prev, after: e.target.value }))}
              className="border border-gray-300 rounded px-3 py-2"
            />
            <span className="text-gray-500">to</span>
            <input
              type="date"
              value={dateRange.before}
              onChange={(e) => setDateRange(prev => ({ ...prev, before: e.target.value }))}
              className="border border-gray-300 rounded px-3 py-2"
            />
            <button
              onClick={loadDashboardData}
              className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
            >
              Refresh
            </button>
          </div>
        </div>

        {/* Metrics */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
          <MetricCard
            title="Total Events"
            value={data?.totalEvents || 0}
            loading={loading}
          />
          <MetricCard
            title="Unique Users"
            value={data?.uniqueUsers || 0}
            loading={loading}
          />
          <MetricCard
            title="Avg Events/User"
            value={data ? (data.totalEvents / data.uniqueUsers).toFixed(1) : '0'}
            loading={loading}
          />
          <MetricCard
            title="Date Range"
            value={`${dateRange.after} to ${dateRange.before}`}
            loading={loading}
          />
        </div>

        {/* Charts */}
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
          <EventTimelineChart
            events={data?.recentEvents || []}
            loading={loading}
          />
          <UserActivityChart
            events={data?.recentEvents || []}
            loading={loading}
          />
        </div>

        {/* Tables */}
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
          <TopEventsTable
            events={data?.recentEvents || []}
            loading={loading}
          />
          <div className="bg-white rounded-lg shadow p-6">
            <h3 className="text-lg font-medium text-gray-900 mb-4">Recent Events</h3>
            <div className="space-y-2">
              {loading ? (
                Array.from({ length: 5 }).map((_, i) => (
                  <div key={i} className="h-4 bg-gray-200 rounded animate-pulse"></div>
                ))
              ) : (
                data?.recentEvents.slice(0, 10).map((event, i) => (
                  <div key={i} className="flex justify-between items-center text-sm">
                    <span className="font-medium">{event.eventName}</span>
                    <span className="text-gray-500">
                      {new Date(event.eventTs).toLocaleString()}
                    </span>
                  </div>
                ))
              )}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

API Routes for Server-Side Data

For better performance and security, create API routes to proxy requests:
// pages/api/events.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { grainAPI } from '../../lib/grain-api';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const events = await grainAPI.queryEvents(req.body);
    res.json(events);
  } catch (error) {
    console.error('API Error:', error);
    res.status(500).json({ error: 'Failed to fetch events' });
  }
}

// pages/api/count.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const count = await grainAPI.countEvents(req.body);
    res.json({ count });
  } catch (error) {
    console.error('API Error:', error);
    res.status(500).json({ error: 'Failed to count events' });
  }
}

Real-time Updates

Add real-time updates using polling:
// hooks/useRealtimeData.ts
import { useState, useEffect, useRef } from 'react';

export function useRealtimeData<T>(
  fetchFn: () => Promise<T>,
  intervalMs: number = 30000
) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const intervalRef = useRef<NodeJS.Timeout>();

  const fetchData = async () => {
    try {
      setError(null);
      const result = await fetchFn();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to fetch data');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();

    intervalRef.current = setInterval(fetchData, intervalMs);

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [intervalMs]);

  return { data, loading, error, refetch: fetchData };
}

// Usage in dashboard
const { data: realtimeData, loading, error } = useRealtimeData(
  () => grainAPI.countEvents({ after: dateRange.after, before: dateRange.before }),
  30000 // Update every 30 seconds
);

Error Handling and Loading States

// components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('Dashboard Error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="min-h-screen bg-gray-50 flex items-center justify-center">
          <div className="text-center">
            <h1 className="text-2xl font-bold text-gray-900 mb-4">Something went wrong</h1>
            <p className="text-gray-600 mb-4">
              {this.state.error?.message || 'An unexpected error occurred'}
            </p>
            <button
              onClick={() => this.setState({ hasError: false, error: undefined })}
              className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
            >
              Try again
            </button>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

Deployment

1. Build the Project

npm run build

2. Deploy to Vercel

npm install -g vercel
vercel

3. Set Environment Variables

In your Vercel dashboard, add:
  • GRAIN_TENANT_ID
  • GRAIN_API_KEY

Custom Plans

Building a high-traffic dashboard? We offer custom plans with:
  • Higher rate limits: Custom requests per minute/day limits for real-time dashboards
  • Dedicated support: Priority support and SLA guarantees
  • Custom features: Tailored analytics features and integrations
  • Volume discounts: Competitive pricing for high-volume usage
Contact us at [email protected] to discuss your dashboard requirements.

Next Steps