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

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:
| Feature | Recharts | Chart.js (react-chartjs-2) | Nivo | Victory |
|---|---|---|---|---|
| React-native components | Yes | Wrapper | Yes | Yes |
| Bundle size | ~45 KB | ~60 KB | ~50 KB | ~55 KB |
| Composability | Excellent | Limited | Good | Good |
| TypeScript support | Built-in | Partial | Built-in | Built-in |
| SSR compatibility | Yes | Canvas-based (limited) | Yes | Yes |
| Customization | Component-level | Config object | Props | Props |
| Learning curve | Low | Low | Moderate | Moderate |
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-dashboardInstall Recharts:
npm install rechartsYour 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:
ResponsiveContainerwraps every chart to make it responsive. Always setwidth="100%"and a fixedheight.CartesianGridwithstrokeDasharraycreates subtle dashed grid lines.radiusonBarrounds the top corners for a modern look.tickFormatteronYAxisformats 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-imageandjsPDFto 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.
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

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

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.

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.