Build a Real-Time Crypto Dashboard

Build a live crypto dashboard with React, TypeScript, and Tailwind CSS.

Introduction

30 Second Summary

Cryptocurrency prices move every second, and millions of people check dashboards daily to track the market. The tools that power these dashboards are the same ones hiring managers expect to see on your resume.

In this project, you will build a real-time crypto market dashboard using React, TypeScript, and Tailwind CSS that fetches live data from a public API, supports search and sorting, and deploys to a public URL anyone can visit.

What You'll Build

A responsive cryptocurrency dashboard that pulls live market data from the CoinGecko API and displays prices, market caps, and 24-hour changes with search and sort functionality.

By the end of this project, you'll have:

  • A fully typed React application built with TypeScript interfaces and reusable components.
  • A responsive dashboard styled with Tailwind CSS that displays live cryptocurrency prices, market caps, and trends.
  • A deployed project on Vercel with a public URL you can share with recruiters.
  • Secret Mission: Add an interactive coin detail modal with a hand-crafted SVG price chart.

Are there any prerequisites?

You should have basic familiarity with HTML, CSS, JavaScript, and React fundamentals (components, props, useState).

No prior TypeScript or Tailwind CSS experience is needed. You will learn both from scratch in this project.

Set Up the Project

To build a crypto dashboard that looks professional and handles real market data, you need a modern frontend toolchain. React provides the component architecture. TypeScript catches errors before they hit users. Tailwind CSS handles styling without fighting CSS files.

In this step, you will scaffold a React + TypeScript project using Vite, install Tailwind CSS, and confirm everything works with the development server.

In this step, get ready to:
  • Scaffold a React + TypeScript project with Vite.
  • Install and configure Tailwind CSS.
  • Verify the development server and Tailwind utility classes work.
Scaffold the project with Vite

Vite is a modern build tool that scaffolds React projects instantly. The react-ts template gives you React and TypeScript pre-configured from the start.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new React + TypeScript project by running this command:
npm create vite@latest crypto-dashboard -- --template react-ts

What does this command do?

  • npm create vite@latest downloads and runs the latest Vite scaffolding tool.
  • crypto-dashboard is the name of your project folder.
  • -- --template react-ts tells Vite to use the React + TypeScript template so you skip any interactive prompts.
  • Navigate into the new project folder and install its dependencies by running these commands:
cd crypto-dashboard
npm install
  • Confirm the folder was created by running:
ls

You should see files like package.json, vite.config.ts, tsconfig.json, and a src folder listed in the output.

Command not found or scaffold failed?

  • Make sure you have Node.js 20.19+ or 22.12+ installed. Run node --version to check.
  • If you see a permissions error on macOS, try prefixing the command with sudo.

Still stuck? Help me troubleshoot the Vite scaffold command.

  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Visual Studio Code.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the crypto-dashboard folder on your Desktop, then click Open.

Why open the project in VS Code?

VS Code gives you a file explorer, integrated terminal, and syntax highlighting all in one window. You will edit all project files from here for the rest of the project.

Install and configure Tailwind CSS

Tailwind CSS v4 requires zero configuration files. You install two packages, register a Vite plugin, and add a single CSS import.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Install Tailwind CSS and its Vite plugin by running:
npm install tailwindcss @tailwindcss/vite

What are these packages?

  • tailwindcss is the core Tailwind engine that generates utility classes.
  • @tailwindcss/vite is the Vite plugin that connects Tailwind to your build pipeline. No tailwind.config.js or PostCSS setup needed in v4.
  • In the VS Code file explorer (left sidebar), click on vite.config.ts to open it.
  • Replace all the content in this file with the following code:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
});

What does this config do?

  • defineConfig provides type-safe configuration for Vite.
  • react() enables React's fast refresh during development.
  • tailwindcss() processes Tailwind utility classes at build time.
  • Save vite.config.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Now open src/index.css in the file explorer.
  • Select all the existing content (Cmd+A or Ctrl+A) and replace it with this single line:
@import "tailwindcss";

Why just one line?

Tailwind CSS v4 detects your project's utility classes automatically. This single @import replaces the old @tailwind base, @tailwind components, and @tailwind utilities directives from v3.

  • Save src/index.css.
  • Now open index.html in the file explorer (it is in the project root, not inside src).
  • Find this line:
<title>Vite + React + TS</title>
  • Replace it with:
<title>Crypto Market Dashboard</title>
  • Save index.html.

Not sure which file to edit?

  • vite.config.ts is at the top level of your project (same level as package.json).
  • index.css is inside the src folder.
  • index.html is at the top level, next to vite.config.ts.

Still can't find a file? Help me find project files in VS Code.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is what each configured file should look like:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
});
@import "tailwindcss";
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Crypto Market Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
Verify the setup works

Before building any components, confirm that Vite serves your app and Tailwind processes utility classes correctly.

  • Start the development server by running this command in the integrated terminal:
npm run dev

You should see a local URL printed in the terminal (usually http://localhost:5173). Hold Cmd (macOS) or Ctrl (Windows) and click the URL to open it in your browser.

What should I see?

You should see the default Vite + React starter page with a Vite logo, a React logo, and a counter button. This confirms your React + TypeScript setup is working.

  • Now test that Tailwind is working. Open src/App.tsx in the file explorer.
  • Find the first <h1> tag in the file.
  • Add className="text-red-500" to it so it looks like <h1 className="text-red-500">.
  • Save src/App.tsx.

Check your browser. The heading text should now appear red. This confirms Tailwind is processing utility classes correctly.

You can undo the test change

You will replace the entire App.tsx file in the next step, so it does not matter whether you remove text-red-500 now or leave it.

Text not turning red?

  • Make sure src/index.css contains only @import "tailwindcss"; with no leftover CSS from the scaffold.
  • Confirm vite.config.ts includes tailwindcss() in the plugins array.
  • If you see an error in the terminal, stop the server with Ctrl+C and re-run npm run dev.

Still not working? Help me debug why Tailwind classes are not applying.

Your development environment is ready. Next up, you will define TypeScript interfaces and build the API layer that fetches live cryptocurrency data.

Define TypeScript Interfaces and API Layer

Your project is scaffolded and Tailwind is configured. Now you need to connect your dashboard to real cryptocurrency data.

The CoinGecko API returns rich JSON objects with prices, market caps, and sparkline trends. Without TypeScript interfaces, you would have no way to know what fields exist on those objects until something breaks at runtime. In this step, you will define typed interfaces that describe the API response shape, then build an API layer that fetches live data with full type safety.

In this step, get ready to:
  • Define TypeScript interfaces that describe the CoinGecko API response.
  • Build an API layer with typed async functions for fetching coin and global market data.
  • Wire the API into your React app and confirm live data appears in the browser console.
Create TypeScript interfaces for the API response

The CoinGecko /coins/markets endpoint returns an array of coin objects. Each object has fields like current_price, market_cap, and a nested sparkline_in_7d object containing a price array. You will describe this shape as a TypeScript interface so your editor can autocomplete field names and catch typos.

  • Create a new file called src/types.ts inside your crypto-dashboard project folder.
  • Add the Coin interface by pasting this code:
export interface Coin {
  id: string;
  symbol: string;
  name: string;
  image: string;
  current_price: number;
  market_cap: number;
  market_cap_rank: number;
  price_change_percentage_24h: number;
  sparkline_in_7d: {
    price: number[];
  };
}

What does this interface do?

Each field maps directly to a property in the CoinGecko JSON response. The sparkline_in_7d field is a nested object containing a price array of numbers representing 7 days of price history.

By exporting this interface, every component that handles coin data gets autocompletion and compile-time checks. A typo like coin.curent_price becomes a red squiggle instead of a silent bug.

  • Below the Coin interface, add the GlobalData interface and the sort-related types:
export interface GlobalData {
  data: {
    active_cryptocurrencies: number;
    total_market_cap: { usd: number };
    total_volume: { usd: number };
    market_cap_change_percentage_24h_usd: number;
  };
}

export type SortKey = "market_cap" | "current_price" | "price_change_percentage_24h";
export type SortDirection = "asc" | "desc";

What are these extra types for?

The GlobalData interface describes the response from CoinGecko's /global endpoint, which returns aggregate market stats like total market cap and 24h volume.

The SortKey and SortDirection types are union types that restrict sorting to specific valid values. You will use these in a later step when adding column-header sorting.

  • Save src/types.ts.

Seeing red squiggles in VS Code?

Make sure you saved the file as types.ts (not types.tsx). Files without JSX should use the .ts extension. If VS Code shows an error on export, check that the file is inside the src/ folder.

Help me debug TypeScript interface errors in my types.ts file.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/types.ts before moving on.

ⓧ I'd like to double check the full code

export interface Coin {
  id: string;
  symbol: string;
  name: string;
  image: string;
  current_price: number;
  market_cap: number;
  market_cap_rank: number;
  price_change_percentage_24h: number;
  sparkline_in_7d: {
    price: number[];
  };
}

export interface GlobalData {
  data: {
    active_cryptocurrencies: number;
    total_market_cap: { usd: number };
    total_volume: { usd: number };
    market_cap_change_percentage_24h_usd: number;
  };
}

export type SortKey = "market_cap" | "current_price" | "price_change_percentage_24h";
export type SortDirection = "asc" | "desc";
Build the API fetching layer

Now you need functions that call the CoinGecko API and return typed data. Centralizing API calls in one file keeps your components clean and makes it easy to swap data sources later.

  • Create a new file called src/api.ts in your src/ folder.
  • Add the imports and the fetchCoins function by pasting this code:
import { Coin, GlobalData } from "./types";

const BASE_URL = "https://api.coingecko.com/api/v3";

export async function fetchCoins(): Promise<Coin[]> {
  const response = await fetch(
    `${BASE_URL}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=20&page=1&sparkline=true`
  );

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

What does this code do?

The BASE_URL constant stores the CoinGecko API root so you only define it once. The fetchCoins function calls the /coins/markets endpoint with parameters that request the top 20 coins by market cap, priced in USD, with 7-day sparkline data included.

The if (!response.ok) check throws an error if the API returns a non-200 status code. The return type Promise<Coin[]> guarantees that anything consuming this function gets a typed array of coins.

  • Below the fetchCoins function, add the fetchGlobalData function:
export async function fetchGlobalData(): Promise<GlobalData> {
  const response = await fetch(`${BASE_URL}/global`);

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

What does fetchGlobalData do?

This function calls CoinGecko's /global endpoint, which returns aggregate stats for the entire crypto market. You will use it in a later step to display total market cap and trading volume at the top of your dashboard.

  • Save src/api.ts.

Seeing import errors on Coin or GlobalData?

Make sure src/types.ts is saved and that your import path is "./types" (with a dot-slash prefix). TypeScript needs the relative path to find the file.

Help me fix import errors in my api.ts file.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/api.ts before moving on.

ⓧ I'd like to double check the full code

import { Coin, GlobalData } from "./types";

const BASE_URL = "https://api.coingecko.com/api/v3";

export async function fetchCoins(): Promise<Coin[]> {
  const response = await fetch(
    `${BASE_URL}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=20&page=1&sparkline=true`
  );

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

export async function fetchGlobalData(): Promise<GlobalData> {
  const response = await fetch(`${BASE_URL}/global`);

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}
Test the API call in your app

Your types and API functions are ready. Now you need to verify they actually work by calling the API from your React app and checking the browser console for live data.

  • Open src/App.tsx in VS Code.
  • Replace the entire file contents with this code:
import { useEffect, useState } from "react";
import { Coin } from "./types";
import { fetchCoins } from "./api";

export default function App() {
  // Store the fetched coins in state
  const [coins, setCoins] = useState<Coin[]>([]);

  // Fetch data once when the component mounts
  useEffect(() => {
    fetchCoins().then((data) => {
      setCoins(data);
      console.log(data);
    });
  }, []);

  return <div>{coins.length} coins loaded</div>;
}

What does this code do?

The useState<Coin[]>([]) hook creates a typed state variable that starts as an empty array. TypeScript now knows coins is always an array of Coin objects.

The useEffect with an empty dependency array [] runs once when the component first renders. It calls fetchCoins(), stores the result in state, and logs it to the console so you can inspect the response shape.

  • Save src/App.tsx.
  • Switch to your browser where the dev server is running. The page should now display 20 coins loaded.
  • Open your browser's Developer Tools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • In the Console tab, you should see an array of 20 objects. Expand one to confirm fields like id, current_price, market_cap, and sparkline_in_7d.price are present.

Seeing 0 coins loaded or a blank page?

Check the Console tab for errors. If you see a CORS or 429 error, the CoinGecko keyless API has a shared rate limit. Wait 30-60 seconds and refresh the page.

If you see an import error, make sure src/types.ts and src/api.ts are both saved. The import paths in App.tsx must be "./types" and "./api" (relative paths with dot-slash).

Help me debug why my CoinGecko API call is not returning data.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/App.tsx before moving on.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";
import { Coin } from "./types";
import { fetchCoins } from "./api";

export default function App() {
  const [coins, setCoins] = useState<Coin[]>([]);

  useEffect(() => {
    fetchCoins().then((data) => {
      setCoins(data);
      console.log(data);
    });
  }, []);

  return <div>{coins.length} coins loaded</div>;
}

You now have a fully typed API layer that fetches live cryptocurrency data. Next up, you will build the dashboard UI components that display this data in a table with search functionality.

Build the Dashboard UI

Your API layer is fetching real cryptocurrency data and your TypeScript types ensure every property is typed correctly.

Now it's time to turn that raw data into something visual. In this step, you'll build three reusable React components that display the coin data in a professional dashboard layout.

In this step, get ready to:
  • Build a CoinTable component that displays coin data with formatted prices and market caps.
  • Create a SearchBar component that filters coins by name or symbol.
  • Add a GlobalStats header showing live market-wide data.
Create the CoinTable component

The coin table is the core of your dashboard. It takes an array of coins and renders each one as a row with formatted prices, percentage changes in color, and abbreviated market caps.

  • In the VS Code Explorer sidebar, right-click the src folder and select New Folder.
  • Name the folder components.
  • Right-click the new components folder and select New File.
  • Name the file CoinTable.tsx.
  • Paste the following code into CoinTable.tsx:
import { Coin, SortKey, SortDirection } from "../types";

// Define the props this component expects from its parent
interface CoinTableProps {
  coins: Coin[];
  sortKey: SortKey;
  sortDirection: SortDirection;
  onSort: (key: SortKey) => void;
}

// Format a price as USD with appropriate decimal places
function formatPrice(price: number): string {
  return price.toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
    maximumFractionDigits: price < 1 ? 6 : 2,
  });
}

// Abbreviate large numbers (trillions, billions, millions)
function formatMarketCap(cap: number): string {
  if (cap >= 1e12) return `$${(cap / 1e12).toFixed(2)}T`;
  if (cap >= 1e9) return `$${(cap / 1e9).toFixed(2)}B`;
  if (cap >= 1e6) return `$${(cap / 1e6).toFixed(2)}M`;
  return `$${cap.toLocaleString()}`;
}

// Small arrow indicator showing which column is sorted and in which direction
function SortIndicator({ columnKey, activeKey, direction }: { columnKey: SortKey; activeKey: SortKey; direction: SortDirection }) {
  if (columnKey !== activeKey) return <span className="ml-1 text-gray-600">&#8597;</span>;
  return <span className="ml-1">{direction === "asc" ? "&#9650;" : "&#9660;"}</span>;
}

export default function CoinTable({ coins, sortKey, sortDirection, onSort }: CoinTableProps) {
  return (
    <div className="overflow-x-auto rounded-lg">
      <table className="w-full text-left text-sm">
        <thead className="border-b border-gray-700 text-gray-400">
          <tr>
            <th className="px-4 py-3">#</th>
            <th className="px-4 py-3">Coin</th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("current_price")}>
              Price <SortIndicator columnKey="current_price" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("price_change_percentage_24h")}>
              24h % <SortIndicator columnKey="price_change_percentage_24h" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("market_cap")}>
              Market Cap <SortIndicator columnKey="market_cap" activeKey={sortKey} direction={sortDirection} />
            </th>
          </tr>
        </thead>
        <tbody>
          {coins.map((coin) => (
            <tr key={coin.id} className="border-b border-gray-800 transition-colors hover:bg-gray-800">
              <td className="px-4 py-3 text-gray-400">{coin.market_cap_rank}</td>
              <td className="px-4 py-3">
                <div className="flex items-center gap-2">
                  <img src={coin.image} alt={coin.name} className="h-6 w-6" />
                  <span className="font-medium">{coin.name}</span>
                  <span className="uppercase text-gray-400">{coin.symbol}</span>
                </div>
              </td>
              <td className="px-4 py-3">{formatPrice(coin.current_price)}</td>
              <td className={`px-4 py-3 ${coin.price_change_percentage_24h >= 0 ? "text-green-400" : "text-red-400"}`}>
                {coin.price_change_percentage_24h?.toFixed(2)}%
              </td>
              <td className="px-4 py-3">{formatMarketCap(coin.market_cap)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this code do?

This component has three layers:

  • CoinTableProps defines a TypeScript interface so the parent component must pass coins, sort state, and a sort handler.
  • formatPrice and formatMarketCap are helper functions that turn raw numbers into readable strings like $1.2T or $65,432.10.
  • The table body uses coins.map() to render one row per coin, with conditional coloring (green for gains, red for losses) on the 24h change column.
  • Save CoinTable.tsx.

You won't see a change in the browser yet because App.tsx doesn't import this component yet.

Seeing red underlines in VS Code?

  • Check that your file is inside src/components/ (not directly in src/). The import path "../types" goes up one level from components/ to reach src/.
  • Make sure src/types.ts exports Coin, SortKey, and SortDirection. These were created in the previous step.

Help me fix TypeScript import errors in my CoinTable component.

✔️ Awesome, I've got everything!

Great. Make sure you've saved CoinTable.tsx before moving on.

ⓧ I'd like to double check the full code

import { Coin, SortKey, SortDirection } from "../types";

interface CoinTableProps {
  coins: Coin[];
  sortKey: SortKey;
  sortDirection: SortDirection;
  onSort: (key: SortKey) => void;
}

function formatPrice(price: number): string {
  return price.toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
    maximumFractionDigits: price < 1 ? 6 : 2,
  });
}

function formatMarketCap(cap: number): string {
  if (cap >= 1e12) return `$${(cap / 1e12).toFixed(2)}T`;
  if (cap >= 1e9) return `$${(cap / 1e9).toFixed(2)}B`;
  if (cap >= 1e6) return `$${(cap / 1e6).toFixed(2)}M`;
  return `$${cap.toLocaleString()}`;
}

function SortIndicator({ columnKey, activeKey, direction }: { columnKey: SortKey; activeKey: SortKey; direction: SortDirection }) {
  if (columnKey !== activeKey) return <span className="ml-1 text-gray-600">&#8597;</span>;
  return <span className="ml-1">{direction === "asc" ? "&#9650;" : "&#9660;"}</span>;
}

export default function CoinTable({ coins, sortKey, sortDirection, onSort }: CoinTableProps) {
  return (
    <div className="overflow-x-auto rounded-lg">
      <table className="w-full text-left text-sm">
        <thead className="border-b border-gray-700 text-gray-400">
          <tr>
            <th className="px-4 py-3">#</th>
            <th className="px-4 py-3">Coin</th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("current_price")}>
              Price <SortIndicator columnKey="current_price" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("price_change_percentage_24h")}>
              24h % <SortIndicator columnKey="price_change_percentage_24h" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("market_cap")}>
              Market Cap <SortIndicator columnKey="market_cap" activeKey={sortKey} direction={sortDirection} />
            </th>
          </tr>
        </thead>
        <tbody>
          {coins.map((coin) => (
            <tr key={coin.id} className="border-b border-gray-800 transition-colors hover:bg-gray-800">
              <td className="px-4 py-3 text-gray-400">{coin.market_cap_rank}</td>
              <td className="px-4 py-3">
                <div className="flex items-center gap-2">
                  <img src={coin.image} alt={coin.name} className="h-6 w-6" />
                  <span className="font-medium">{coin.name}</span>
                  <span className="uppercase text-gray-400">{coin.symbol}</span>
                </div>
              </td>
              <td className="px-4 py-3">{formatPrice(coin.current_price)}</td>
              <td className={`px-4 py-3 ${coin.price_change_percentage_24h >= 0 ? "text-green-400" : "text-red-400"}`}>
                {coin.price_change_percentage_24h?.toFixed(2)}%
              </td>
              <td className="px-4 py-3">{formatMarketCap(coin.market_cap)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
Create the SearchBar component

A search bar lets users quickly find specific coins without scrolling through the full table. This component is intentionally simple. It receives a search term and a callback from its parent, keeping state management centralized in App.tsx.

  • Right-click the src/components folder and select New File.
  • Name the file SearchBar.tsx.
  • Paste the following code into SearchBar.tsx:
// Define the props: a controlled input with value and change handler
interface SearchBarProps {
  searchTerm: string;
  onSearchChange: (term: string) => void;
}

export default function SearchBar({ searchTerm, onSearchChange }: SearchBarProps) {
  return (
    <input
      type="text"
      placeholder="Search coins..."
      value={searchTerm}
      onChange={(e) => onSearchChange(e.target.value)}
      className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
    />
  );
}

What does this code do?

  • SearchBarProps types the component's contract: it receives a searchTerm string and an onSearchChange callback.
  • This is a controlled input pattern. The parent owns the state, and this component just displays and relays changes back up.
  • The Tailwind CSS classes give it a dark background, rounded corners, and a blue focus ring that matches the dashboard theme.
  • Save SearchBar.tsx.

You won't see a change yet. The next substep wires this component into your app.

✔️ Awesome, I've got everything!

Great. Make sure SearchBar.tsx is saved and move on.

ⓧ I'd like to double check the full code

interface SearchBarProps {
  searchTerm: string;
  onSearchChange: (term: string) => void;
}

export default function SearchBar({ searchTerm, onSearchChange }: SearchBarProps) {
  return (
    <input
      type="text"
      placeholder="Search coins..."
      value={searchTerm}
      onChange={(e) => onSearchChange(e.target.value)}
      className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
    />
  );
}
Create the GlobalStats component and wire everything together

The GlobalStats component fetches market-wide data from a separate endpoint and displays it as a stats bar above the table. After creating it, you'll update App.tsx to import all three components and add search filtering logic.

  • Right-click the src/components folder and select New File.
  • Name the file GlobalStats.tsx.
  • Paste the following code into GlobalStats.tsx:
import { useEffect, useState } from "react";
import { GlobalData } from "../types";
import { fetchGlobalData } from "../api";

// Abbreviate large numbers for display
function formatLargeNumber(num: number): string {
  if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
  if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
  if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
  return `$${num.toLocaleString()}`;
}

export default function GlobalStats() {
  const [globalData, setGlobalData] = useState<GlobalData | null>(null);

  useEffect(() => {
    fetchGlobalData()
      .then(setGlobalData)
      .catch(console.error);
  }, []);

  // Return nothing until data loads
  if (!globalData) return null;

  const { active_cryptocurrencies, total_market_cap, total_volume, market_cap_change_percentage_24h_usd } =
    globalData.data;

  return (
    <div className="flex flex-wrap gap-6 rounded-lg bg-gray-800 p-4 text-sm">
      <div>
        <span className="text-gray-400">Market Cap: </span>
        <span className="font-semibold">{formatLargeNumber(total_market_cap.usd)}</span>
        <span className={`ml-1 ${market_cap_change_percentage_24h_usd >= 0 ? "text-green-400" : "text-red-400"}`}>
          {market_cap_change_percentage_24h_usd.toFixed(2)}%
        </span>
      </div>
      <div>
        <span className="text-gray-400">24h Volume: </span>
        <span className="font-semibold">{formatLargeNumber(total_volume.usd)}</span>
      </div>
      <div>
        <span className="text-gray-400">Active Coins: </span>
        <span className="font-semibold">{active_cryptocurrencies.toLocaleString()}</span>
      </div>
    </div>
  );
}

What does this code do?

  • This component manages its own data fetching. It calls fetchGlobalData() inside a useEffect hook when it first mounts.
  • It returns null until data arrives, preventing a flash of empty content.
  • Once loaded, it destructures the response and displays total market cap (with a green/red percent change), 24h trading volume, and active coin count.
  • Save GlobalStats.tsx.

Seeing an import error for fetchGlobalData?

Make sure src/api.ts exports the fetchGlobalData function. You created it in the previous step alongside fetchCoins.

Help me debug a missing export in my api.ts file.

✔️ Awesome, I've got everything!

Great. Save GlobalStats.tsx and continue to the App update below.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";
import { GlobalData } from "../types";
import { fetchGlobalData } from "../api";

function formatLargeNumber(num: number): string {
  if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
  if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
  if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
  return `$${num.toLocaleString()}`;
}

export default function GlobalStats() {
  const [globalData, setGlobalData] = useState<GlobalData | null>(null);

  useEffect(() => {
    fetchGlobalData()
      .then(setGlobalData)
      .catch(console.error);
  }, []);

  if (!globalData) return null;

  const { active_cryptocurrencies, total_market_cap, total_volume, market_cap_change_percentage_24h_usd } =
    globalData.data;

  return (
    <div className="flex flex-wrap gap-6 rounded-lg bg-gray-800 p-4 text-sm">
      <div>
        <span className="text-gray-400">Market Cap: </span>
        <span className="font-semibold">{formatLargeNumber(total_market_cap.usd)}</span>
        <span className={`ml-1 ${market_cap_change_percentage_24h_usd >= 0 ? "text-green-400" : "text-red-400"}`}>
          {market_cap_change_percentage_24h_usd.toFixed(2)}%
        </span>
      </div>
      <div>
        <span className="text-gray-400">24h Volume: </span>
        <span className="font-semibold">{formatLargeNumber(total_volume.usd)}</span>
      </div>
      <div>
        <span className="text-gray-400">Active Coins: </span>
        <span className="font-semibold">{active_cryptocurrencies.toLocaleString()}</span>
      </div>
    </div>
  );
}

Now you have all three components ready. Time to replace the placeholder content in App.tsx with the real dashboard layout.

  • Open src/App.tsx in the editor.
  • Select all the existing content (press Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Paste this updated code into App.tsx:
import { useEffect, useState } from "react";
import { Coin, SortKey, SortDirection } from "./types";
import { fetchCoins } from "./api";
import GlobalStats from "./components/GlobalStats";
import SearchBar from "./components/SearchBar";
import CoinTable from "./components/CoinTable";

export default function App() {
  // State for coin data, loading, and error handling
  const [coins, setCoins] = useState<Coin[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [searchTerm, setSearchTerm] = useState("");
  const [sortKey, setSortKey] = useState<SortKey>("market_cap");
  const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

  useEffect(() => {
    fetchCoins()
      .then((data) => {
        setCoins(data);
        setLastUpdated(new Date());
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  // Toggle sort direction if same key, otherwise switch to new key
  function handleSort(key: SortKey) {
    if (key === sortKey) {
      setSortDirection(sortDirection === "asc" ? "desc" : "asc");
    } else {
      setSortKey(key);
      setSortDirection("desc");
    }
  }

  // Filter by search term, then sort by the active column
  const filteredCoins = coins
    .filter(
      (coin) =>
        coin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
        coin.symbol.toLowerCase().includes(searchTerm.toLowerCase())
    )
    .sort((a, b) => {
      const multiplier = sortDirection === "asc" ? 1 : -1;
      return (a[sortKey] - b[sortKey]) * multiplier;
    });

  // Show a spinner while data is loading
  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent mx-auto"></div>
          <p>Loading market data...</p>
        </div>
      </div>
    );
  }

  // Show an error message with a retry button if the fetch failed
  if (error) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <p className="text-red-400">Failed to load data: {error}</p>
          <button
            onClick={() => window.location.reload()}
            className="mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
          >
            Retry
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-900 text-white">
      <div className="mx-auto max-w-6xl px-4 py-8">
        <header className="mb-8">
          <h1 className="mb-2 text-3xl font-bold">Crypto Market Dashboard</h1>
          {lastUpdated && (
            <p className="text-sm text-gray-400">
              Last updated: {lastUpdated.toLocaleTimeString()}
            </p>
          )}
        </header>

        <GlobalStats />

        <div className="my-6">
          <SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
        </div>

        <CoinTable
          coins={filteredCoins}
          sortKey={sortKey}
          sortDirection={sortDirection}
          onSort={handleSort}
        />
      </div>
    </div>
  );
}

What does this code do?

This is the main orchestrator for your dashboard. It manages all the shared state and passes slices of it down to each child component.

  • loading and error state lets you show a spinner or error message instead of a blank screen while data loads.
  • searchTerm filters the coin array by name or symbol before passing it to CoinTable.
  • handleSort toggles sort direction when you click the same column twice, or switches to a new column in descending order.
  • filteredCoins applies both the search filter and the active sort before rendering.
  • Save App.tsx.
  • Switch to your browser where the dev server is running.

You should see a dark-themed dashboard with a stats bar at the top, a search input, and a table of 20 cryptocurrencies with their prices and market caps.

Test search and sort

Try typing eth in the search bar. Only Ethereum should remain visible.

Click the Price column header to sort coins by price. Click it again to reverse the direction.

Seeing a blank screen or errors?

  • Check the terminal running your dev server for compile errors. A missing import or typo in a file path is the most common cause.
  • Verify that all three component files are inside src/components/ (not src/ directly).
  • If you see "Loading market data..." that never resolves, open the browser console (press F12) and look for network errors. The CoinGecko API has rate limits and may return a 429 status if you refresh too frequently.

Help me debug my crypto dashboard. The page is blank or showing an error.

✔️ Awesome, I've got everything!

Great. Double check you've saved all files before continuing.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";
import { Coin, SortKey, SortDirection } from "./types";
import { fetchCoins } from "./api";
import GlobalStats from "./components/GlobalStats";
import SearchBar from "./components/SearchBar";
import CoinTable from "./components/CoinTable";

export default function App() {
  const [coins, setCoins] = useState<Coin[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [searchTerm, setSearchTerm] = useState("");
  const [sortKey, setSortKey] = useState<SortKey>("market_cap");
  const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

  useEffect(() => {
    fetchCoins()
      .then((data) => {
        setCoins(data);
        setLastUpdated(new Date());
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  function handleSort(key: SortKey) {
    if (key === sortKey) {
      setSortDirection(sortDirection === "asc" ? "desc" : "asc");
    } else {
      setSortKey(key);
      setSortDirection("desc");
    }
  }

  const filteredCoins = coins
    .filter(
      (coin) =>
        coin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
        coin.symbol.toLowerCase().includes(searchTerm.toLowerCase())
    )
    .sort((a, b) => {
      const multiplier = sortDirection === "asc" ? 1 : -1;
      return (a[sortKey] - b[sortKey]) * multiplier;
    });

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent mx-auto"></div>
          <p>Loading market data...</p>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <p className="text-red-400">Failed to load data: {error}</p>
          <button
            onClick={() => window.location.reload()}
            className="mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
          >
            Retry
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-900 text-white">
      <div className="mx-auto max-w-6xl px-4 py-8">
        <header className="mb-8">
          <h1 className="mb-2 text-3xl font-bold">Crypto Market Dashboard</h1>
          {lastUpdated && (
            <p className="text-sm text-gray-400">
              Last updated: {lastUpdated.toLocaleTimeString()}
            </p>
          )}
        </header>

        <GlobalStats />

        <div className="my-6">
          <SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
        </div>

        <CoinTable
          coins={filteredCoins}
          sortKey={sortKey}
          sortDirection={sortDirection}
          onSort={handleSort}
        />
      </div>
    </div>
  );
}

Your dashboard is taking shape. You have a live stats header, a searchable coin table, and proper loading and error states. Next up, you'll add sorting polish, responsive styling, and deploy the whole thing to a live URL.

Add Sorting, Loading States, and Polish

Your dashboard displays live data and filters coins by search. That's functional, but a production-quality dashboard needs more.

Users expect to sort columns by clicking headers, see feedback while data loads, and get helpful error messages when something goes wrong. In this step, you'll add all three of these features to transform your prototype into a polished, professional app.

In this step, get ready to:
  • Add clickable column headers that sort coins by price, 24h change, or market cap.
  • Show a loading spinner and error state with a retry button.
  • Apply a dark theme with responsive layout and hover effects.
Add sort functionality to the table

Sorting lets users reorder the table by clicking a column header. The logic lives in two places: App.tsx tracks which column is active and whether the direction is ascending or descending. CoinTable.tsx renders clickable headers with a visual indicator.

  • In src/components/CoinTable.tsx, replace the entire file contents with this updated version that accepts sort props and renders clickable headers:
import { Coin, SortKey, SortDirection } from "../types";

interface CoinTableProps {
  coins: Coin[];
  sortKey: SortKey;
  sortDirection: SortDirection;
  onSort: (key: SortKey) => void;
}

function formatPrice(price: number): string {
  return price.toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
    maximumFractionDigits: price < 1 ? 6 : 2,
  });
}

function formatMarketCap(cap: number): string {
  if (cap >= 1e12) return `$${(cap / 1e12).toFixed(2)}T`;
  if (cap >= 1e9) return `$${(cap / 1e9).toFixed(2)}B`;
  if (cap >= 1e6) return `$${(cap / 1e6).toFixed(2)}M`;
  return `$${cap.toLocaleString()}`;
}

function SortIndicator({ columnKey, activeKey, direction }: { columnKey: SortKey; activeKey: SortKey; direction: SortDirection }) {
  if (columnKey !== activeKey) return <span className="ml-1 text-gray-600">&#8597;</span>;
  return <span className="ml-1">{direction === "asc" ? "&#9650;" : "&#9660;"}</span>;
}

export default function CoinTable({ coins, sortKey, sortDirection, onSort }: CoinTableProps) {
  return (
    <div className="overflow-x-auto rounded-lg">
      <table className="w-full text-left text-sm">
        <thead className="border-b border-gray-700 text-gray-400">
          <tr>
            <th className="px-4 py-3">#</th>
            <th className="px-4 py-3">Coin</th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("current_price")}>
              Price <SortIndicator columnKey="current_price" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("price_change_percentage_24h")}>
              24h % <SortIndicator columnKey="price_change_percentage_24h" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("market_cap")}>
              Market Cap <SortIndicator columnKey="market_cap" activeKey={sortKey} direction={sortDirection} />
            </th>
          </tr>
        </thead>
        <tbody>
          {coins.map((coin) => (
            <tr key={coin.id} className="border-b border-gray-800 transition-colors hover:bg-gray-800">
              <td className="px-4 py-3 text-gray-400">{coin.market_cap_rank}</td>
              <td className="px-4 py-3">
                <div className="flex items-center gap-2">
                  <img src={coin.image} alt={coin.name} className="h-6 w-6" />
                  <span className="font-medium">{coin.name}</span>
                  <span className="uppercase text-gray-400">{coin.symbol}</span>
                </div>
              </td>
              <td className="px-4 py-3">{formatPrice(coin.current_price)}</td>
              <td className={`px-4 py-3 ${coin.price_change_percentage_24h >= 0 ? "text-green-400" : "text-red-400"}`}>
                {coin.price_change_percentage_24h?.toFixed(2)}%
              </td>
              <td className="px-4 py-3">{formatMarketCap(coin.market_cap)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this code do?

The SortIndicator component shows an up/down arrow next to the active column. Inactive columns get a neutral bidirectional arrow to hint they're clickable.

Each sortable <th> has cursor-pointer for the pointer cursor and an onClick that calls onSort with the column's sort key. The actual sorting logic lives in App.tsx so the table stays a pure display component.

Now you need the sorting logic in App.tsx that handles these sort events.

  • In src/App.tsx, find the handleSort function and the filteredCoins derived array. Confirm they look like this:
  function handleSort(key: SortKey) {
    if (key === sortKey) {
      setSortDirection(sortDirection === "asc" ? "desc" : "asc");
    } else {
      setSortKey(key);
      setSortDirection("desc");
    }
  }

  const filteredCoins = coins
    .filter(
      (coin) =>
        coin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
        coin.symbol.toLowerCase().includes(searchTerm.toLowerCase())
    )
    .sort((a, b) => {
      const multiplier = sortDirection === "asc" ? 1 : -1;
      return (a[sortKey] - b[sortKey]) * multiplier;
    });

How does the sort toggle work?

When the user clicks the same column twice, the direction flips between ascending and descending. When they click a different column, it resets to descending (highest first). This matches how financial dashboards typically behave.

The filteredCoins array chains .filter() (for search) with .sort() (for column sorting). The multiplier flips the comparison result for ascending vs descending order.

  • Save CoinTable.tsx and App.tsx.
  • In your browser, click the Price column header. The table should reorder coins by price (highest first). Click it again to flip to lowest first.

Clicking headers does nothing?

  • Check that the CoinTable component in App.tsx passes sortKey, sortDirection, and onSort={handleSort} as props.
  • Confirm your CoinTableProps interface includes sortKey, sortDirection, and onSort fields.
  • If you see a TypeScript error about missing props, make sure the destructured parameter in CoinTable matches the interface exactly.

Why aren't my sort indicators showing up?

Add loading and error states

Real API calls take time to complete and can fail. Without feedback, users stare at a blank page wondering if the app is broken.

  • In src/App.tsx, confirm your state declarations include loading, error, and lastUpdated. These should already be present from the previous step:
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
  • Now confirm the loading return block appears before the main return in your App component. This is what users see while data fetches:
  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent mx-auto"></div>
          <p>Loading market data...</p>
        </div>
      </div>
    );
  }

How does the spinner work?

The spinner is a pure CSS animation. A div with border-4 creates a thick border ring. Making one side transparent (border-t-transparent) creates the gap. The animate-spin class from Tailwind CSS rotates it continuously.

  • Below the loading block, confirm the error return block is present:
  if (error) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <p className="text-red-400">Failed to load data: {error}</p>
          <button
            onClick={() => window.location.reload()}
            className="mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
          >
            Retry
          </button>
        </div>
      </div>
    );
  }

Why show the error message from the API?

The {error} variable contains the message from the caught exception. Showing it helps users (and you during debugging) understand what went wrong. The retry button calls window.location.reload() to re-fetch fresh data.

  • Finally, confirm the lastUpdated timestamp appears in your header section:
        <header className="mb-8">
          <h1 className="mb-2 text-3xl font-bold">Crypto Market Dashboard</h1>
          {lastUpdated && (
            <p className="text-sm text-gray-400">
              Last updated: {lastUpdated.toLocaleTimeString()}
            </p>
          )}
        </header>
  • Save App.tsx.
  • Hard-refresh your browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows). You should briefly see the spinning blue circle before data loads.
  • After data loads, check the header. You should see a "Last updated" timestamp below the title.

Don't see the loading spinner?

  • The API might respond too fast on your connection. Try throttling your network in DevTools: open Network tab, set throttling to Slow 3G, then refresh. You'll see the spinner clearly.
  • Make sure loading starts as true in your useState call. If it starts as false, the spinner never appears.

Help me debug my loading state in App.tsx.

Make the layout fully responsive with a dark theme

A dark color scheme reduces eye strain for a data-heavy dashboard. Responsive design ensures the table remains usable on smaller screens by scrolling horizontally instead of squishing columns.

  • In your App.tsx main return, confirm the outer wrapper uses these Tailwind classes:
  return (
    <div className="min-h-screen bg-gray-900 text-white">
      <div className="mx-auto max-w-6xl px-4 py-8">
        {/* header, GlobalStats, SearchBar, CoinTable */}
      </div>
    </div>
  );

What do these layout classes do?

  • min-h-screen makes the background fill the entire viewport height, even if content is short.
  • bg-gray-900 text-white sets the dark theme. All child text inherits white unless overridden.
  • mx-auto max-w-6xl centers content and caps it at a readable width on large monitors.
  • px-4 py-8 adds horizontal padding so content doesn't touch screen edges on mobile.
  • In CoinTable.tsx, confirm the table wrapper has overflow-x-auto and each table row has hover:bg-gray-800:
    <div className="overflow-x-auto rounded-lg">
      {/* table */}
    </div>

    {/* Each row in tbody: */}
    <tr key={coin.id} className="border-b border-gray-800 transition-colors hover:bg-gray-800">

Why overflow-x-auto?

On mobile screens, a five-column table won't fit. Without overflow-x-auto, columns would squish and text would overlap. With it, the table scrolls sideways while the rest of the page stays fixed.

  • Save both files and refresh your browser.
  • You should see a dark background filling the full viewport, white text, and a centered layout.
  • Hover over any table row. It should subtly highlight with a lighter gray background.
  • Resize your browser window to a narrow width (or open DevTools and toggle the mobile view). The table should scroll horizontally without breaking the page layout.

Page still has a white background?

  • Check that your src/index.css contains only @import "tailwindcss"; with no leftover Vite starter styles that override your classes.
  • Make sure the outermost <div> in your main return has bg-gray-900. If it's only on the loading/error returns, the main view won't get the dark theme.

Help me fix my dark theme Tailwind classes.

✔️ Awesome, I've got everything!

Great. Double check you've saved both App.tsx and CoinTable.tsx.

ⓧ I'd like to double check the full code

Here is the complete src/components/CoinTable.tsx:

import { Coin, SortKey, SortDirection } from "../types";

interface CoinTableProps {
  coins: Coin[];
  sortKey: SortKey;
  sortDirection: SortDirection;
  onSort: (key: SortKey) => void;
}

function formatPrice(price: number): string {
  return price.toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
    maximumFractionDigits: price < 1 ? 6 : 2,
  });
}

function formatMarketCap(cap: number): string {
  if (cap >= 1e12) return `$${(cap / 1e12).toFixed(2)}T`;
  if (cap >= 1e9) return `$${(cap / 1e9).toFixed(2)}B`;
  if (cap >= 1e6) return `$${(cap / 1e6).toFixed(2)}M`;
  return `$${cap.toLocaleString()}`;
}

function SortIndicator({ columnKey, activeKey, direction }: { columnKey: SortKey; activeKey: SortKey; direction: SortDirection }) {
  if (columnKey !== activeKey) return <span className="ml-1 text-gray-600">&#8597;</span>;
  return <span className="ml-1">{direction === "asc" ? "&#9650;" : "&#9660;"}</span>;
}

export default function CoinTable({ coins, sortKey, sortDirection, onSort }: CoinTableProps) {
  return (
    <div className="overflow-x-auto rounded-lg">
      <table className="w-full text-left text-sm">
        <thead className="border-b border-gray-700 text-gray-400">
          <tr>
            <th className="px-4 py-3">#</th>
            <th className="px-4 py-3">Coin</th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("current_price")}>
              Price <SortIndicator columnKey="current_price" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("price_change_percentage_24h")}>
              24h % <SortIndicator columnKey="price_change_percentage_24h" activeKey={sortKey} direction={sortDirection} />
            </th>
            <th className="cursor-pointer px-4 py-3" onClick={() => onSort("market_cap")}>
              Market Cap <SortIndicator columnKey="market_cap" activeKey={sortKey} direction={sortDirection} />
            </th>
          </tr>
        </thead>
        <tbody>
          {coins.map((coin) => (
            <tr key={coin.id} className="border-b border-gray-800 transition-colors hover:bg-gray-800">
              <td className="px-4 py-3 text-gray-400">{coin.market_cap_rank}</td>
              <td className="px-4 py-3">
                <div className="flex items-center gap-2">
                  <img src={coin.image} alt={coin.name} className="h-6 w-6" />
                  <span className="font-medium">{coin.name}</span>
                  <span className="uppercase text-gray-400">{coin.symbol}</span>
                </div>
              </td>
              <td className="px-4 py-3">{formatPrice(coin.current_price)}</td>
              <td className={`px-4 py-3 ${coin.price_change_percentage_24h >= 0 ? "text-green-400" : "text-red-400"}`}>
                {coin.price_change_percentage_24h?.toFixed(2)}%
              </td>
              <td className="px-4 py-3">{formatMarketCap(coin.market_cap)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

And here is the complete src/App.tsx:

import { useEffect, useState } from "react";
import { Coin, SortKey, SortDirection } from "./types";
import { fetchCoins } from "./api";
import GlobalStats from "./components/GlobalStats";
import SearchBar from "./components/SearchBar";
import CoinTable from "./components/CoinTable";

export default function App() {
  const [coins, setCoins] = useState<Coin[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [searchTerm, setSearchTerm] = useState("");
  const [sortKey, setSortKey] = useState<SortKey>("market_cap");
  const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

  useEffect(() => {
    fetchCoins()
      .then((data) => {
        setCoins(data);
        setLastUpdated(new Date());
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  function handleSort(key: SortKey) {
    if (key === sortKey) {
      setSortDirection(sortDirection === "asc" ? "desc" : "asc");
    } else {
      setSortKey(key);
      setSortDirection("desc");
    }
  }

  const filteredCoins = coins
    .filter(
      (coin) =>
        coin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
        coin.symbol.toLowerCase().includes(searchTerm.toLowerCase())
    )
    .sort((a, b) => {
      const multiplier = sortDirection === "asc" ? 1 : -1;
      return (a[sortKey] - b[sortKey]) * multiplier;
    });

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent mx-auto"></div>
          <p>Loading market data...</p>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
        <div className="text-center">
          <p className="text-red-400">Failed to load data: {error}</p>
          <button
            onClick={() => window.location.reload()}
            className="mt-4 rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
          >
            Retry
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-900 text-white">
      <div className="mx-auto max-w-6xl px-4 py-8">
        <header className="mb-8">
          <h1 className="mb-2 text-3xl font-bold">Crypto Market Dashboard</h1>
          {lastUpdated && (
            <p className="text-sm text-gray-400">
              Last updated: {lastUpdated.toLocaleTimeString()}
            </p>
          )}
        </header>

        <GlobalStats />

        <div className="my-6">
          <SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
        </div>

        <CoinTable
          coins={filteredCoins}
          sortKey={sortKey}
          sortDirection={sortDirection}
          onSort={handleSort}
        />
      </div>
    </div>
  );
}

Your dashboard now sorts interactively, handles loading gracefully, and looks like a professional trading tool. Next up, you'll deploy it to a live URL that anyone can visit.

Deploy to Vercel

Your dashboard works perfectly on your machine. The problem is that nobody else can see it there.

In this step, you'll push your code to GitHub and deploy it to Vercel. Within minutes, you'll have a live public URL that anyone can open and interact with.

In this step, get ready to:
  • Initialize a Git repository and push your code to GitHub.
  • Deploy your dashboard to Vercel with a live public URL.
  • Verify the live deployment works on desktop and mobile.
Initialize a Git repository

Before you can deploy, your code needs to live in a Git repository on GitHub. Vercel connects directly to GitHub and rebuilds your site every time you push new code.

  • Switch back to your terminal in VS Code. Make sure you are inside the crypto-dashboard folder.
  • Initialize a new Git repository by running this command:
git init

What about .gitignore?

Vite already created a .gitignore file when you scaffolded the project. It excludes node_modules and build outputs so they don't get pushed to GitHub.

  • Stage all your files and create your first commit by running these commands:
git add .
git commit -m "Initial commit: crypto dashboard"
  • You should see a message listing all the files committed.

Seeing an error about identity?

If Git asks you to set your name and email, run these two commands (replacing the placeholder values with your own) and then re-run the commit command above.

git config --global user.email "your-email@example.com"
git config --global user.name "Your Name"

Help me fix my Git identity error.

Now you need a GitHub repository to push your code to.

  • Open your browser and go to github.com. Sign in to your account.
  • Click the + icon in the top right corner.
  • Click New repository.
  • Enter crypto-dashboard as the repository name.
  • Leave the repository set to Public.
  • Do NOT check any of the initialization options (README, .gitignore, license). You already have your own files.
  • Click Create repository.

GitHub shows you a quick setup page with commands. You only need the two commands that connect your local repo and push the code.

  • Back in your VS Code terminal, link your local repository to GitHub and push your code by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/crypto-dashboard.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin connects your local repo to the GitHub repo you just created.
  • git branch -M main renames your default branch to main.
  • git push -u origin main uploads all your code to GitHub.
  • Refresh your GitHub repository page in the browser. You should see all your project files listed.

Push failed or asking for credentials?

If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS pushes. You need a Personal Access Token or SSH key configured. Follow GitHub's authentication docs to set one up, then retry the push.

Help me push my code to GitHub.

Deploy to Vercel

Vercel is a hosting platform that auto-detects your build tool and deploys your site in under 60 seconds. The free Hobby plan is perfect for portfolio projects.

  • Open vercel.com in your browser.
  • Click Sign Up.
  • Select Continue with GitHub to sign up using your GitHub account.
  • Choose the free Hobby plan when prompted.

What is the Hobby plan?

Vercel's Hobby plan is free for personal, non-commercial projects. It includes 100 GB bandwidth per month, which is far more than a portfolio project needs.

Once you are signed in to Vercel, you can import your GitHub repo.

  • Click Add New Project (or Add New... then Project).
  • Find your crypto-dashboard repository in the list and click Import.

Don't see your repository?

If your repo doesn't appear, click the link to adjust your GitHub App permissions. Grant Vercel access to the crypto-dashboard repository (or all repositories), then return to the import page.

  • On the project configuration page, Vercel auto-detects Vite and pre-fills the build settings. Leave everything at the defaults.
  • Click Deploy.

Vercel builds and deploys your project. This typically takes under 60 seconds. When it finishes, you'll see a celebration screen with a preview of your site and a live URL.

Build failed?

Click the deployment to see the build logs. The most common cause is a TypeScript error that your local dev server didn't catch. Fix any errors shown in the log, commit, and push again. Vercel will automatically redeploy.

Help me debug my Vercel build failure.

Verify the live deployment

A deployment only counts if it actually works. Let's confirm everything runs correctly at the public URL.

  • Click your live URL on the Vercel dashboard (it looks like https://crypto-dashboard-xxx.vercel.app).
  • Confirm the coin table loads with live prices and market data.
  • Type a coin name into the search bar and confirm the table filters correctly.
  • Click a column header (like Price or 24h %) and confirm sorting works.
  • Open the same URL on your phone (or use your browser's mobile emulation in DevTools). Confirm the table scrolls horizontally and the layout adapts.
  • Copy your live URL. This goes on your resume and your GitHub repository's README.

Why verify on mobile?

Recruiters often open portfolio links on their phones while reviewing applications. A dashboard that breaks on mobile suggests you don't test your own work. The responsive Tailwind classes you added ensure it looks professional on any screen size.

Secret mission

Coin Detail Modal with Price Chart

Your dashboard shows the market at a glance, but what happens when someone wants to dig deeper into a specific coin? In this secret mission, you'll build an interactive modal that fetches 7-day historical price data and renders it as a hand-crafted SVG line chart. No charting library needed.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and deploys to Vercel's free Hobby plan, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local crypto-dashboard project folder on your Desktop.
  • GitHub repository for your crypto dashboard code.
  • Vercel project with a live deployment on the free Hobby plan.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your live dashboard URL accessible.

  • Your Vercel deployment stays live at its public URL for free.
  • Your GitHub repository remains available for future updates.
  • Your local crypto-dashboard folder stays on your Desktop.

Pause - I'll come back to this later

Stop any running local processes to free up memory. Your Vercel deployment and GitHub repository stay available at no cost.

  • In your terminal, press Ctrl+C to stop the Vite dev server if it is still running.
  • Your Vercel deployment continues to serve your dashboard at its public URL for free.
  • Your GitHub repository stays intact for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes your local files, GitHub repository, and Vercel deployment permanently.

  • Stop the dev server by pressing Ctrl+C in your terminal if it is still running.
  • Delete the local project folder by running this command from your Desktop:

macOS

rm -rf ~/Desktop/crypto-dashboard

Windows

rmdir /s /q %USERPROFILE%\Desktop\crypto-dashboard
  • Delete the GitHub repository:
  • Open your repository on GitHub.
  • Click Settings in the top menu bar.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Confirm by typing your repository name, then click Delete this repository.
  • Delete the Vercel project:
  • Open the Vercel dashboard and select your crypto dashboard project.
  • Click Settings in the sidebar.
  • Scroll to the bottom of the General page and click Delete in the Delete Project section.
  • Enter the project name to confirm, then click Continue.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a real-time cryptocurrency market dashboard using React, TypeScript, and Tailwind CSS.

You've learned how to:

  • Build a fully typed React + TypeScript application from scratch with interfaces, typed props, and generic state hooks.
  • Fetch and display live data from the CoinGecko API with search filtering, column sorting, loading states, and error handling.
  • Style a responsive, professional dashboard with Tailwind CSS and deploy it to a live public URL on Vercel.
  • Secret Mission: Add an interactive coin detail modal with a hand-crafted SVG price chart that fetches 7-day historical data on click.

Ready to quiz yourself?