Recharts + Next.js App Router: Building Interactive Data Visualization Dashboards

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

Data tells a story — charts make it unforgettable. Recharts is the most popular React-native charting library, built on composable components and powered by D3 under the hood. In this tutorial, you will build a complete sales analytics dashboard with Next.js 15 App Router, covering every chart type and pattern you need for production.

What You Will Learn

By the end of this tutorial, you will:

  • Set up Recharts in a Next.js 15 App Router project with TypeScript
  • Build bar charts, line charts, area charts, and pie charts from scratch
  • Create custom tooltips and interactive legends
  • Make charts fully responsive with ResponsiveContainer
  • Fetch and display dynamic data from API routes
  • Add smooth animations and hover interactions
  • Support dark mode with theme-aware chart colors
  • Organize everything into a production-ready dashboard layout

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, interfaces, generics)
  • Familiarity with Next.js App Router basics (layouts, pages, client/server components)
  • Basic understanding of Tailwind CSS for layout styling

Why Recharts in 2026?

When it comes to charting in React, several libraries compete for attention. Here is why Recharts remains the top choice:

FeatureRechartsChart.js (react-chartjs-2)NivoVictory
React-native componentsYesWrapperYesYes
Bundle size~45 KB~60 KB~50 KB~55 KB
ComposabilityExcellentLimitedGoodGood
TypeScript supportBuilt-inPartialBuilt-inBuilt-in
SSR compatibilityYesCanvas-based (limited)YesYes
CustomizationComponent-levelConfig objectPropsProps
Learning curveLowLowModerateModerate

Recharts stands out because every element — axes, grids, tooltips, legends — is a React component you can compose, style, and extend. If you know React, you already know how to use Recharts.


Step 1: Project Setup

Create a new Next.js 15 project with TypeScript and Tailwind CSS:

npx create-next-app@latest sales-dashboard --typescript --tailwind --app --src-dir
cd sales-dashboard

Install Recharts:

npm install recharts

Your project structure should look like this:

sales-dashboard/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── api/
│   │       └── sales/
│   │           └── route.ts
│   ├── components/
│   │   └── charts/
│   │       ├── BarChartCard.tsx
│   │       ├── LineChartCard.tsx
│   │       ├── AreaChartCard.tsx
│   │       ├── PieChartCard.tsx
│   │       └── CustomTooltip.tsx
│   └── lib/
│       └── data.ts
├── package.json
└── tsconfig.json

Step 2: Define the Data Layer

Create src/lib/data.ts with typed sample data for your dashboard:

export interface MonthlySales {
  month: string;
  revenue: number;
  orders: number;
  customers: number;
}
 
export interface ProductSales {
  name: string;
  value: number;
  color: string;
}
 
export const monthlySalesData: MonthlySales[] = [
  { month: "Jan", revenue: 4200, orders: 180, customers: 120 },
  { month: "Feb", revenue: 5100, orders: 210, customers: 145 },
  { month: "Mar", revenue: 4800, orders: 195, customers: 130 },
  { month: "Apr", revenue: 6300, orders: 270, customers: 180 },
  { month: "May", revenue: 5900, orders: 250, customers: 165 },
  { month: "Jun", revenue: 7200, orders: 310, customers: 210 },
  { month: "Jul", revenue: 6800, orders: 290, customers: 195 },
  { month: "Aug", revenue: 7500, orders: 320, customers: 225 },
  { month: "Sep", revenue: 8100, orders: 350, customers: 240 },
  { month: "Oct", revenue: 7800, orders: 335, customers: 230 },
  { month: "Nov", revenue: 9200, orders: 400, customers: 280 },
  { month: "Dec", revenue: 10500, orders: 450, customers: 320 },
];
 
export const productSalesData: ProductSales[] = [
  { name: "Electronics", value: 35, color: "#6366f1" },
  { name: "Clothing", value: 25, color: "#8b5cf6" },
  { name: "Home & Garden", value: 20, color: "#a78bfa" },
  { name: "Sports", value: 12, color: "#c4b5fd" },
  { name: "Books", value: 8, color: "#ddd6fe" },
];

This gives you two datasets: time-series monthly sales and categorical product distribution. Both are fully typed for TypeScript safety.


Step 3: Build a Bar Chart

Create src/components/charts/BarChartCard.tsx. The bar chart will show monthly revenue:

"use client";
 
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";
import { MonthlySales } from "@/lib/data";
 
interface BarChartCardProps {
  data: MonthlySales[];
}
 
export default function BarChartCard({ data }: BarChartCardProps) {
  return (
    <div className="rounded-xl border bg-white p-6 shadow-sm dark:bg-gray-900 dark:border-gray-800">
      <h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
        Monthly Revenue
      </h3>
      <ResponsiveContainer width="100%" height={300}>
        <BarChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
          <XAxis
            dataKey="month"
            tick={{ fontSize: 12, fill: "#6b7280" }}
            axisLine={{ stroke: "#d1d5db" }}
          />
          <YAxis
            tick={{ fontSize: 12, fill: "#6b7280" }}
            axisLine={{ stroke: "#d1d5db" }}
            tickFormatter={(value) => `$${value / 1000}k`}
          />
          <Tooltip
            formatter={(value: number) => [`$${value.toLocaleString()}`, "Revenue"]}
            contentStyle={{
              backgroundColor: "#1f2937",
              border: "none",
              borderRadius: "8px",
              color: "#f9fafb",
            }}
          />
          <Bar
            dataKey="revenue"
            fill="#6366f1"
            radius={[4, 4, 0, 0]}
            animationDuration={800}
          />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

Key points:

  • ResponsiveContainer wraps every chart to make it responsive. Always set width="100%" and a fixed height.
  • CartesianGrid with strokeDasharray creates subtle dashed grid lines.
  • radius on Bar rounds the top corners for a modern look.
  • tickFormatter on YAxis formats large numbers as $4k, $8k, etc.

Step 4: Build a Line Chart

Create src/components/charts/LineChartCard.tsx to visualize trends over time:

"use client";
 
import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from "recharts";
import { MonthlySales } from "@/lib/data";
 
interface LineChartCardProps {
  data: MonthlySales[];
}
 
export default function LineChartCard({ data }: LineChartCardProps) {
  return (
    <div className="rounded-xl border bg-white p-6 shadow-sm dark:bg-gray-900 dark:border-gray-800">
      <h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
        Orders vs Customers
      </h3>
      <ResponsiveContainer width="100%" height={300}>
        <LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
          <XAxis
            dataKey="month"
            tick={{ fontSize: 12, fill: "#6b7280" }}
          />
          <YAxis tick={{ fontSize: 12, fill: "#6b7280" }} />
          <Tooltip
            contentStyle={{
              backgroundColor: "#1f2937",
              border: "none",
              borderRadius: "8px",
              color: "#f9fafb",
            }}
          />
          <Legend
            wrapperStyle={{ paddingTop: "12px" }}
            iconType="circle"
          />
          <Line
            type="monotone"
            dataKey="orders"
            stroke="#6366f1"
            strokeWidth={2}
            dot={{ fill: "#6366f1", r: 4 }}
            activeDot={{ r: 6, stroke: "#6366f1", strokeWidth: 2 }}
            animationDuration={1000}
          />
          <Line
            type="monotone"
            dataKey="customers"
            stroke="#10b981"
            strokeWidth={2}
            dot={{ fill: "#10b981", r: 4 }}
            activeDot={{ r: 6, stroke: "#10b981", strokeWidth: 2 }}
            animationDuration={1200}
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

Notice how you can overlay multiple Line components on the same chart. Each gets its own color, and the Legend component automatically picks up the dataKey names. The type="monotone" prop creates smooth curves instead of sharp angles.


Step 5: Build an Area Chart with Gradients

Create src/components/charts/AreaChartCard.tsx. Area charts are perfect for showing volume over time:

"use client";
 
import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";
import { MonthlySales } from "@/lib/data";
 
interface AreaChartCardProps {
  data: MonthlySales[];
}
 
export default function AreaChartCard({ data }: AreaChartCardProps) {
  return (
    <div className="rounded-xl border bg-white p-6 shadow-sm dark:bg-gray-900 dark:border-gray-800">
      <h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
        Revenue Trend
      </h3>
      <ResponsiveContainer width="100%" height={300}>
        <AreaChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
          <defs>
            <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
            </linearGradient>
          </defs>
          <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
          <XAxis
            dataKey="month"
            tick={{ fontSize: 12, fill: "#6b7280" }}
          />
          <YAxis
            tick={{ fontSize: 12, fill: "#6b7280" }}
            tickFormatter={(value) => `$${value / 1000}k`}
          />
          <Tooltip
            formatter={(value: number) => [`$${value.toLocaleString()}`, "Revenue"]}
            contentStyle={{
              backgroundColor: "#1f2937",
              border: "none",
              borderRadius: "8px",
              color: "#f9fafb",
            }}
          />
          <Area
            type="monotone"
            dataKey="revenue"
            stroke="#6366f1"
            strokeWidth={2}
            fill="url(#revenueGradient)"
            animationDuration={1000}
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
}

The secret sauce here is SVG gradients. The defs block defines a linearGradient that fades from 30% opacity at the top to fully transparent at the bottom. The Area component references this gradient via fill="url(#revenueGradient)". This creates a polished, professional look that flat fills cannot achieve.


Step 6: Build a Pie Chart

Create src/components/charts/PieChartCard.tsx for categorical data:

"use client";
 
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from "recharts";
import { ProductSales } from "@/lib/data";
 
interface PieChartCardProps {
  data: ProductSales[];
}
 
export default function PieChartCard({ data }: PieChartCardProps) {
  return (
    <div className="rounded-xl border bg-white p-6 shadow-sm dark:bg-gray-900 dark:border-gray-800">
      <h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
        Sales by Category
      </h3>
      <ResponsiveContainer width="100%" height={300}>
        <PieChart>
          <Pie
            data={data}
            cx="50%"
            cy="50%"
            innerRadius={60}
            outerRadius={100}
            paddingAngle={3}
            dataKey="value"
            animationDuration={800}
            label={({ name, percent }) =>
              `${name} ${(percent * 100).toFixed(0)}%`
            }
            labelLine={{ stroke: "#6b7280" }}
          >
            {data.map((entry, index) => (
              <Cell key={`cell-${index}`} fill={entry.color} />
            ))}
          </Pie>
          <Tooltip
            formatter={(value: number) => [`${value}%`, "Share"]}
            contentStyle={{
              backgroundColor: "#1f2937",
              border: "none",
              borderRadius: "8px",
              color: "#f9fafb",
            }}
          />
          <Legend iconType="circle" />
        </PieChart>
      </ResponsiveContainer>
    </div>
  );
}

Setting innerRadius creates a donut chart instead of a full pie. The paddingAngle adds subtle gaps between slices. Each Cell component lets you assign individual colors from your data.


Step 7: Build a Custom Tooltip

Default tooltips work fine, but custom tooltips give you full control over the design. Create src/components/charts/CustomTooltip.tsx:

"use client";
 
interface CustomTooltipProps {
  active?: boolean;
  payload?: Array<{
    name: string;
    value: number;
    color: string;
  }>;
  label?: string;
}
 
export default function CustomTooltip({
  active,
  payload,
  label,
}: CustomTooltipProps) {
  if (!active || !payload || payload.length === 0) {
    return null;
  }
 
  return (
    <div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
      <p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
        {label}
      </p>
      {payload.map((item, index) => (
        <div key={index} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-3 w-3 rounded-full"
            style={{ backgroundColor: item.color }}
          />
          <span className="text-gray-600 dark:text-gray-400">{item.name}:</span>
          <span className="font-semibold text-gray-900 dark:text-white">
            {typeof item.value === "number"
              ? item.value.toLocaleString()
              : item.value}
          </span>
        </div>
      ))}
    </div>
  );
}

Use it in any chart by replacing the inline Tooltip with:

<Tooltip content={<CustomTooltip />} />

The custom tooltip receives active, payload, and label as props from Recharts. You render whatever JSX you want — Tailwind classes, icons, sparklines, anything React can render.


Step 8: Create an API Route for Dynamic Data

In a real dashboard, data comes from an API. Create src/app/api/sales/route.ts:

import { NextResponse } from "next/server";
import { monthlySalesData, productSalesData } from "@/lib/data";
 
export async function GET() {
  // Simulate API delay
  await new Promise((resolve) => setTimeout(resolve, 300));
 
  return NextResponse.json({
    monthly: monthlySalesData,
    products: productSalesData,
    summary: {
      totalRevenue: monthlySalesData.reduce((sum, m) => sum + m.revenue, 0),
      totalOrders: monthlySalesData.reduce((sum, m) => sum + m.orders, 0),
      totalCustomers: monthlySalesData.reduce((sum, m) => sum + m.customers, 0),
      avgOrderValue: Math.round(
        monthlySalesData.reduce((sum, m) => sum + m.revenue, 0) /
          monthlySalesData.reduce((sum, m) => sum + m.orders, 0)
      ),
    },
  });
}

This API route returns all dashboard data in a single request. In production, you would replace the static data with database queries or external API calls.


Step 9: Assemble the Dashboard Page

Now bring everything together in src/app/page.tsx:

"use client";
 
import { useEffect, useState } from "react";
import BarChartCard from "@/components/charts/BarChartCard";
import LineChartCard from "@/components/charts/LineChartCard";
import AreaChartCard from "@/components/charts/AreaChartCard";
import PieChartCard from "@/components/charts/PieChartCard";
import { MonthlySales, ProductSales } from "@/lib/data";
 
interface DashboardData {
  monthly: MonthlySales[];
  products: ProductSales[];
  summary: {
    totalRevenue: number;
    totalOrders: number;
    totalCustomers: number;
    avgOrderValue: number;
  };
}
 
export default function DashboardPage() {
  const [data, setData] = useState<DashboardData | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    fetch("/api/sales")
      .then((res) => res.json())
      .then((json) => {
        setData(json);
        setLoading(false);
      });
  }, []);
 
  if (loading || !data) {
    return (
      <div className="flex min-h-screen items-center justify-center">
        <div className="h-8 w-8 animate-spin rounded-full border-4 border-indigo-500 border-t-transparent" />
      </div>
    );
  }
 
  const stats = [
    { label: "Total Revenue", value: `$${data.summary.totalRevenue.toLocaleString()}` },
    { label: "Total Orders", value: data.summary.totalOrders.toLocaleString() },
    { label: "Customers", value: data.summary.totalCustomers.toLocaleString() },
    { label: "Avg Order Value", value: `$${data.summary.avgOrderValue}` },
  ];
 
  return (
    <main className="min-h-screen bg-gray-50 p-6 dark:bg-gray-950">
      <div className="mx-auto max-w-7xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900 dark:text-white">
          Sales Analytics Dashboard
        </h1>
 
        {/* Summary Cards */}
        <div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
          {stats.map((stat) => (
            <div
              key={stat.label}
              className="rounded-xl border bg-white p-6 shadow-sm dark:bg-gray-900 dark:border-gray-800"
            >
              <p className="text-sm text-gray-500 dark:text-gray-400">
                {stat.label}
              </p>
              <p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
                {stat.value}
              </p>
            </div>
          ))}
        </div>
 
        {/* Charts Grid */}
        <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
          <BarChartCard data={data.monthly} />
          <LineChartCard data={data.monthly} />
          <AreaChartCard data={data.monthly} />
          <PieChartCard data={data.products} />
        </div>
      </div>
    </main>
  );
}

This page fetches data from the API, displays summary statistics in a top row, and renders all four chart types in a responsive 2-column grid. On mobile, the charts stack vertically thanks to the lg:grid-cols-2 breakpoint.


Step 10: Add Dark Mode Support

Recharts does not have built-in dark mode, but you can make it theme-aware using CSS variables or a React context. Here is a lightweight approach using a custom hook:

// src/lib/useChartTheme.ts
"use client";
 
import { useEffect, useState } from "react";
 
export interface ChartTheme {
  gridColor: string;
  tickColor: string;
  tooltipBg: string;
  tooltipText: string;
}
 
const lightTheme: ChartTheme = {
  gridColor: "#e5e7eb",
  tickColor: "#6b7280",
  tooltipBg: "#1f2937",
  tooltipText: "#f9fafb",
};
 
const darkTheme: ChartTheme = {
  gridColor: "#374151",
  tickColor: "#9ca3af",
  tooltipBg: "#f9fafb",
  tooltipText: "#1f2937",
};
 
export function useChartTheme(): ChartTheme {
  const [isDark, setIsDark] = useState(false);
 
  useEffect(() => {
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    setIsDark(mq.matches);
    const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
    mq.addEventListener("change", handler);
    return () => mq.removeEventListener("change", handler);
  }, []);
 
  return isDark ? darkTheme : lightTheme;
}

Then use the theme in your chart components:

import { useChartTheme } from "@/lib/useChartTheme";
 
export default function BarChartCard({ data }: BarChartCardProps) {
  const theme = useChartTheme();
 
  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={data}>
        <CartesianGrid strokeDasharray="3 3" stroke={theme.gridColor} />
        <XAxis tick={{ fill: theme.tickColor }} />
        <YAxis tick={{ fill: theme.tickColor }} />
        <Tooltip
          contentStyle={{
            backgroundColor: theme.tooltipBg,
            color: theme.tooltipText,
          }}
        />
        <Bar dataKey="revenue" fill="#6366f1" />
      </BarChart>
    </ResponsiveContainer>
  );
}

Now your charts adapt automatically when the user switches between light and dark mode.


Step 11: Performance Optimization

When rendering many charts on a single page, keep these tips in mind:

Lazy Load Charts Below the Fold

Only render charts when they scroll into view:

"use client";
 
import { useEffect, useRef, useState, ReactNode } from "react";
 
interface LazyChartProps {
  children: ReactNode;
  height?: number;
}
 
export default function LazyChart({ children, height = 400 }: LazyChartProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin: "100px" }
    );
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);
 
  return (
    <div ref={ref} style={{ minHeight: height }}>
      {visible ? children : null}
    </div>
  );
}

Wrap any chart component with LazyChart to defer rendering until it enters the viewport.

Reduce Animation Duration

For dashboards with more than 4 charts, consider reducing animationDuration to 400ms or disabling animations entirely with isAnimationActive={false} to speed up the initial paint.

Memoize Chart Data

If your data is derived from API responses, wrap transformations in useMemo to avoid recalculating on every render:

const chartData = useMemo(
  () => rawData.map((item) => ({ ...item, revenue: item.revenue / 100 })),
  [rawData]
);

Troubleshooting

Charts not rendering on the server

Recharts uses the browser DOM and will not render during server-side rendering. Always mark chart components with "use client" and wrap them in ResponsiveContainer. If you see hydration mismatches, ensure the chart component is only rendered on the client.

ResponsiveContainer has zero height

ResponsiveContainer inherits its dimensions from its parent. If the parent has no explicit height, the chart collapses. Always set a height prop on ResponsiveContainer or ensure the parent container has a defined height.

Tooltip flickers on mobile

On touch devices, tooltips can flicker because touch events fire differently. Set allowEscapeViewBox on the Tooltip to prevent it from being clipped at chart boundaries:

<Tooltip allowEscapeViewBox={{ x: true, y: true }} />

Next Steps

Now that your dashboard is functional, consider these enhancements:

  • Date range picker — let users filter data by custom time periods
  • Export to PDF — use html-to-image and jsPDF to export the dashboard
  • Real-time updates — connect to WebSockets or Server-Sent Events for live data
  • Drill-down charts — click a bar to see detailed breakdown data
  • Stacked and grouped bar charts — compare multiple categories side by side
  • Brush component — add a range selector for zooming into specific time periods

Recharts supports all of these out of the box with its composable component model.


Conclusion

In this tutorial, you built a complete sales analytics dashboard with Recharts and Next.js App Router. You learned how to:

  • Create four different chart types — bar, line, area, and pie charts
  • Build custom tooltips for a polished user experience
  • Make charts responsive with ResponsiveContainer
  • Fetch dynamic data from Next.js API routes
  • Support dark mode with theme-aware chart colors
  • Optimize performance with lazy loading and memoization

Recharts is a powerful choice because it treats every chart element as a composable React component. You style it, extend it, and test it the same way you handle any other component in your application. Pair it with Tailwind CSS for layout and you have a dashboard toolkit that scales from prototypes to production.


Want to read more tutorials? Check out our latest tutorial on Enrolling on El Fatoora: A Practical Step-by-Step Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide

Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

25 min read·

Building a Content-Driven Website with Payload CMS 3 and Next.js

Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.

30 min read·