Build a Personal Finance Dashboard

Connect bank accounts via Plaid and visualize spending in a React dashboard.

Introduction

30 Second Summary

Every modern banking app lets you see your spending at a glance. Behind the scenes, services like Plaid connect securely to thousands of financial institutions, pull transaction data, and normalize it so apps can display your money in one place.

In this project, you will build a personal finance dashboard that mirrors the core architecture of OpenAI's ChatGPT Finances feature. You'll connect bank accounts through Plaid Link, sync transactions using cursor-based pagination, and visualize spending data in an interactive React dashboard.

What You'll Build

A full-stack personal finance dashboard with an Express backend that handles secure Plaid API integration, and a React frontend that visualizes account balances, spending by category, and recent transactions.

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

  • An Express backend with secure Plaid API integration that creates link tokens, exchanges public tokens for permanent access tokens, and syncs transactions.
  • A React frontend with a Plaid Link account connection flow that lets users connect their bank accounts in a modal.
  • An interactive dashboard with spending-by-category bar charts, account balance cards, and a recent transactions list built with Recharts.
  • Secret Mission: Add a recurring transactions detector that identifies subscriptions and regular payments from your transaction history.

Are there any prerequisites?

You should have basic familiarity with JavaScript and React. You'll also need Node.js installed on your machine (v18 or higher). No real bank account is needed. Plaid's free Sandbox environment provides realistic test data.

Set Up the Project

Before you can build a finance dashboard, you need the right runtime, a clean project structure, and credentials to talk to a real banking API.

In this step, you will install Node.js, scaffold separate frontend and backend directories, and create a free Plaid Sandbox account. The Sandbox gives you realistic test data without connecting a real bank account.

In this step, get ready to:
  • Install and verify Node.js v24 LTS.
  • Create a project with separate server and client directories plus all dependencies.
  • Set up a free Plaid developer account and configure your credentials.
Install and verify Node.js

Node.js is the JavaScript runtime that powers both your Express backend and the Vite dev server for your React frontend. This project targets v24 LTS.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 24 or higher

Your terminal shows something like v24.18.0. You are good to go.

  • Confirm npm is also available by running:
npm --version

You should see a version number. npm ships with Node.js, so if Node is installed correctly you already have it.

ⓧ I see an older version

Your terminal shows a version lower than 24 (for example v18.20.0 or v20.11.0). You need to upgrade to v24 LTS.

  • Visit https://nodejs.org and download the LTS installer for your operating system.
  • Run the installer and accept the defaults.
  • Close your terminal, reopen it, and run node --version again.
  • You should now see v24 or higher.

Why close and reopen the terminal?

Your terminal caches the path to executables when it starts. Reopening it forces it to pick up the newly installed Node.js binary.

ⓧ Command not found

Your terminal does not recognize node. Node.js is not installed yet.

  • Visit https://nodejs.org and download the LTS installer for your operating system.
  • Run the installer and accept the defaults.
  • Close your terminal, reopen it, and run node --version again.
  • You should now see v24 or higher.

Still seeing command not found after installing?

Make sure you fully closed your terminal before reopening it. On macOS, quit Terminal from the dock (right-click and Quit). On Windows, close all Command Prompt or PowerShell windows.

help me troubleshoot why node is not found after installing Node.js

Create the project structure and install dependencies

Your finance dashboard has two separate applications: an Express backend (the server) and a React frontend (the client). Keeping them in separate directories mirrors how production teams structure full-stack projects.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir finance-dashboard
cd finance-dashboard
  • Create the server directory and move into it:
mkdir server
cd server

Your server needs a package.json that declares its dependencies and enables ES module import syntax.

  • Create a file called package.json inside the server folder and paste the following content:
{
  "name": "finance-dashboard-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "cors": "2.8.5",
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "plaid": "43.0.0"
  }
}

What does this file do?

The "type": "module" field tells Node.js to use modern ES module import/export syntax instead of the older require() style.

The four dependencies cover your backend needs: express for the web server, plaid for the Plaid API client, cors for cross-origin requests from the React frontend, and dotenv for loading environment variables from a .env file.

  • Install the dependencies by running:
npm install
  • Confirm the install succeeded by running:
ls node_modules

You should see a long list of package folders. This confirms npm install pulled down all four packages and their sub-dependencies.

Seeing permission errors during npm install?

On macOS, avoid using sudo with npm. If you get EACCES errors, your Node.js installation may have been done with root permissions. Reinstall Node from nodejs.org using the official installer, which sets up correct permissions.

help me fix npm permission errors on my machine

✔️ Awesome, I've got everything!

Great. Make sure you saved package.json and that node_modules exists in your server directory.

ⓧ I'd like to double check the full code

Your server/package.json should look exactly like this:

{
  "name": "finance-dashboard-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "cors": "2.8.5",
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "plaid": "43.0.0"
  }
}

Now set up the React frontend. The Vite build tool scaffolds a React project with TypeScript in seconds.

  • Move back to the project root by running:
cd ..
  • Create the React client app by running:
npm create vite@latest client -- --template react-ts

What does this command do?

This runs the create-vite scaffolding tool. It creates a client directory containing a ready-to-run React app with TypeScript configured. The --template react-ts flag tells Vite to use the React + TypeScript template.

  • Move into the new client directory and install its base dependencies:
cd client
npm install
  • Install the two additional packages your frontend needs by running:
npm install react-plaid-link recharts

What are these packages?

The react-plaid-link package provides the usePlaidLink hook that opens Plaid's secure bank-connection modal in the browser. Recharts is a charting library built for React that you will use to visualize spending data.

  • Confirm everything installed by running:
ls node_modules | head -20

You should see a list of package folder names. If you see output, your client dependencies are installed.

npm create vite failing?

If your terminal asks to install create-vite, type y and press Enter to allow it. This prompt appears the first time you use npm create vite on a machine.

help me troubleshoot npm create vite not working

Create a Plaid developer account and configure credentials

Plaid is the financial data API that powers the bank-connection flow in your dashboard. Their Sandbox environment is completely free and uses test data, so you do not need a real bank account.

  • Open https://dashboard.plaid.com/signup in your browser.
  • Fill in your name, email, and a password to create a free account.
  • Verify your email if Plaid sends a confirmation link.
  • Once you are logged in, click Developers in the left sidebar.
  • Click Keys.
  • Copy your client_id value. Keep this tab open.
  • Find the Sandbox secret and copy it as well.

Why Sandbox?

Plaid offers three environments: Sandbox, Development, and Production. Sandbox returns simulated bank data and never touches real accounts. It is free with no rate limits for testing.

Now store these credentials in a .env file so your server can load them at runtime without hardcoding secrets in source code.

  • Move into the server directory by running:
cd ~/Desktop/finance-dashboard/server
  • Create a file called .env in the server folder and paste the following content, replacing the placeholder values with the credentials you copied from the Plaid Dashboard:
PLAID_CLIENT_ID=your-plaid-client-id-here
PLAID_SECRET=your-plaid-sandbox-secret-here
PLAID_ENV=sandbox

What does each variable do?

PLAID_CLIENT_ID identifies your Plaid application. PLAID_SECRET authenticates API requests. PLAID_ENV tells the Plaid SDK which environment to connect to.

  • Save .env.
  • Confirm it exists by running:
ls -a

You should see .env listed alongside package.json and node_modules.

Don't see .env in the listing?

Files starting with a dot are hidden by default. The -a flag in ls -a shows hidden files. If .env is missing, make sure you saved it inside the server folder, not the project root.

help me figure out why my .env file is not showing up

✔️ Awesome, I've got everything!

Great. Double check you replaced the placeholder values with your real Plaid credentials before moving on.

ⓧ I'd like to double check the full code

Your server/.env should follow this structure (with your real values replacing the placeholders):

PLAID_CLIENT_ID=your-plaid-client-id-here
PLAID_SECRET=your-plaid-sandbox-secret-here
PLAID_ENV=sandbox

Your environment is ready. You have Node.js running, a clean project structure with server and client directories, and Plaid credentials configured. Next up, you will build the Express backend that creates link tokens and exchanges them securely.

Build the Plaid Integration Backend

Your project structure and credentials are ready. Now you need to build the backend server that talks to Plaid's API on behalf of your frontend.

The Plaid integration follows a secure three-step token flow: your server creates a link_token, the frontend uses it to open Plaid Link, and the resulting public_token gets exchanged server-side for a permanent access_token. In this step, you will build the first two endpoints of that flow.

In this step, get ready to:
  • Set up an Express server with the Plaid client configured for Sandbox.
  • Create a link token endpoint that initializes Plaid Link sessions.
  • Create a token exchange endpoint that converts short-lived tokens into permanent access tokens.
Set up the Express server and initialize the Plaid client

Before you can create any endpoints, you need an Express server configured with CORS (so the React frontend can make requests) and a Plaid client that authenticates using your Sandbox credentials.

  • Create a new file called index.js inside your server folder.
  • Add the server setup and Plaid client initialization by pasting this code:
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';

dotenv.config();

const app = express();
app.use(cors());
app.use(express.json());

// Configure the Plaid client with Sandbox credentials
const configuration = new Configuration({
  basePath: PlaidEnvironments[process.env.PLAID_ENV],
  baseOptions: {
    headers: {
      'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
      'PLAID-SECRET': process.env.PLAID_SECRET,
    },
  },
});

const plaidClient = new PlaidApi(configuration);

// In-memory storage (simulates a database)
let accessToken = null;
let itemId = null;
let transactionsCursor = null;
let storedTransactions = [];

What does this code do?

The imports pull in Express for the HTTP server, CORS for cross-origin requests, dotenv for loading your .env file, and several named exports from the plaid package.

The Configuration object tells the Plaid SDK which environment to target (sandbox) and which credentials to send in every request header. The four let variables act as a simple in-memory database for storing tokens and transactions later.

Create the link token endpoint

Plaid Link needs a link_token to open. Your server creates one by calling Plaid's /link/token/create endpoint, specifying which products and countries you want.

  • In server/index.js, add the following endpoint below the storedTransactions variable:
app.post('/api/create-link-token', async (req, res) => {
  try {
    // Call Plaid to generate a link token for the frontend
    const response = await plaidClient.linkTokenCreate({
      user: { client_user_id: 'user-1' },
      client_name: 'Finance Dashboard',
      products: [Products.Transactions],
      country_codes: [CountryCode.Us],
      language: 'en',
    });
    res.json({ link_token: response.data.link_token });
  } catch (error) {
    console.error('Error creating link token:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to create link token' });
  }
});

What does this endpoint do?

When the frontend hits POST /api/create-link-token, this handler asks Plaid for a short-lived link_token. That token tells Plaid Link which user is connecting, what products to enable (transactions), and what country to support (US).

The link_token expires after 4 hours. The frontend uses it once to open the Plaid Link modal.

Create the token exchange endpoint

After a user connects their bank in Plaid Link, the frontend receives a public_token. This token is ephemeral and expires after 30 minutes. Your server must exchange it for a permanent access_token that never touches the client.

  • Add the exchange endpoint and server listener below your /api/create-link-token handler:
app.post('/api/exchange-token', async (req, res) => {
  try {
    // Exchange the short-lived public_token for a permanent access_token
    const { public_token } = req.body;
    const response = await plaidClient.itemPublicTokenExchange({
      public_token,
    });
    accessToken = response.data.access_token;
    itemId = response.data.item_id;
    res.json({ success: true });
  } catch (error) {
    console.error('Error exchanging token:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to exchange token' });
  }
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Why does the token exchange matter?

This is the core security pattern of Plaid integrations. The public_token is short-lived and single-use. The access_token it exchanges for is permanent and must never leave your server.

By keeping the access_token server-side only, you ensure that even if your frontend is compromised, an attacker cannot access the user's financial data directly.

  • Save server/index.js.
  • Start your server by running this command from inside your server folder:
node index.js
  • You should see Server running on http://localhost:3001 in your terminal.
  • Test the link token endpoint by running this in a new terminal window:
curl -X POST http://localhost:3001/api/create-link-token
  • You should receive a JSON response containing a link_token that starts with link-sandbox-.

Seeing an error instead of a link_token?

  • Check that your .env file has the correct PLAID_CLIENT_ID and PLAID_SECRET values from your Plaid Dashboard.
  • Make sure PLAID_ENV is set to sandbox (not development or production).
  • Confirm you ran npm install in the server folder. The plaid package must be installed for the imports to resolve.
  • help me debug my Plaid link token endpoint

✔️ Awesome, I've got everything!

Great. Make sure you have saved server/index.js and your server is still running.

ⓧ I'd like to double check the full code

Here is the complete server/index.js file at this point:

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';

dotenv.config();

const app = express();
app.use(cors());
app.use(express.json());

const configuration = new Configuration({
  basePath: PlaidEnvironments[process.env.PLAID_ENV],
  baseOptions: {
    headers: {
      'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
      'PLAID-SECRET': process.env.PLAID_SECRET,
    },
  },
});

const plaidClient = new PlaidApi(configuration);

let accessToken = null;
let itemId = null;
let transactionsCursor = null;
let storedTransactions = [];

app.post('/api/create-link-token', async (req, res) => {
  try {
    const response = await plaidClient.linkTokenCreate({
      user: { client_user_id: 'user-1' },
      client_name: 'Finance Dashboard',
      products: [Products.Transactions],
      country_codes: [CountryCode.Us],
      language: 'en',
    });
    res.json({ link_token: response.data.link_token });
  } catch (error) {
    console.error('Error creating link token:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to create link token' });
  }
});

app.post('/api/exchange-token', async (req, res) => {
  try {
    const { public_token } = req.body;
    const response = await plaidClient.itemPublicTokenExchange({
      public_token,
    });
    accessToken = response.data.access_token;
    itemId = response.data.item_id;
    res.json({ success: true });
  } catch (error) {
    console.error('Error exchanging token:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to exchange token' });
  }
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your backend is live and generating link tokens. Next up, you will build the React frontend that uses this token to open Plaid Link and let users connect their bank accounts.

Connect Accounts with Plaid Link in React

Your backend is running and returning link tokens. Now it's time to build the frontend that actually connects bank accounts.

Plaid Link is a drop-in UI module that handles the entire bank connection flow. You'll use the react-plaid-link library to embed it into your React app as a hook that manages the Link lifecycle for you.

In this step, get ready to:
  • Create a PlaidLinkButton component that fetches a link token and launches Plaid Link.
  • Build the app layout that switches between connection and dashboard views.
  • Style and test the full connection flow with Sandbox credentials.
Create the PlaidLinkButton component

The usePlaidLink hook from react-plaid-link needs a link token to initialize. Your component will fetch one from the backend on mount, pass it to the hook, and get back two things: open (a function to launch Link) and ready (a boolean indicating Link is loaded).

  • Open a new terminal window. Your server terminal is busy running the Express server, so you need a second one for the client.
  • Navigate to your client directory and create the components folder by running these commands:
cd client
mkdir src/components
  • Create a new file called PlaidLinkButton.tsx inside src/components/ and add the following code:
import { useCallback, useEffect, useState } from 'react';
import { usePlaidLink } from 'react-plaid-link';

interface PlaidLinkButtonProps {
  onSuccess: () => void;
}

function PlaidLinkButton({ onSuccess }: PlaidLinkButtonProps) {
  const [linkToken, setLinkToken] = useState<string | null>(null);

  // Fetch a link token from the backend when this component mounts
  useEffect(() => {
    async function fetchLinkToken() {
      const response = await fetch('http://localhost:3001/api/create-link-token', {
        method: 'POST',
      });
      const data = await response.json();
      setLinkToken(data.link_token);
    }
    fetchLinkToken();
  }, []);

  // After Plaid Link succeeds, send the public token to the backend for exchange
  const handlePlaidSuccess = useCallback(
    async (publicToken: string) => {
      await fetch('http://localhost:3001/api/exchange-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ public_token: publicToken }),
      });
      onSuccess();
    },
    [onSuccess]
  );

  // Initialize the Plaid Link hook with the token and success callback
  const { open, ready } = usePlaidLink({
    token: linkToken,
    onSuccess: handlePlaidSuccess,
  });

  return (
    <button onClick={() => open()} disabled={!ready} className="connect-button">
      Connect Your Bank Account
    </button>
  );
}

export default PlaidLinkButton;

What does this code do?

This component handles the entire Plaid Link lifecycle in three phases:

  • useEffect fetches a link_token from your backend the moment the component appears on screen. This token tells Plaid which products and permissions to request.
  • usePlaidLink takes the token and returns open (launches the modal) and ready (true when Link has finished loading). The button stays disabled until ready is true.
  • handlePlaidSuccess fires after the user connects a bank. It sends the short-lived public_token to your backend's /api/exchange-token endpoint, which swaps it for a permanent access_token. Then it calls the onSuccess prop to notify the parent component.
  • Save PlaidLinkButton.tsx.

You won't see a change yet. This component needs the parent App to render it.

Build the app layout with connection state

The App component manages whether the user has connected their bank. Before connection, it shows a landing page with the PlaidLinkButton. After connection, it shows the Dashboard.

  • Create a placeholder Dashboard.tsx file inside src/components/ so the app compiles. You'll build the full dashboard in a later step. Add this code:
function Dashboard() {
  return (
    <div className="dashboard">
      <h1>Your Financial Dashboard</h1>
    </div>
  );
}

export default Dashboard;
  • Now open src/App.tsx (the default Vite template file) and replace its entire contents with this code:
import { useState } from 'react';
import PlaidLinkButton from './components/PlaidLinkButton';
import Dashboard from './components/Dashboard';
import './App.css';

function App() {
  // Track whether the user has connected a bank account
  const [isConnected, setIsConnected] = useState(false);

  // After Plaid Link succeeds, trigger a transaction sync then show the dashboard
  const handleSuccess = async () => {
    await fetch('http://localhost:3001/api/transactions/sync', {
      method: 'POST',
    });
    setIsConnected(true);
  };

  return (
    <div className="app">
      {!isConnected ? (
        <div className="connect-screen">
          <h1>Personal Finance Dashboard</h1>
          <p>Connect your bank account to see your financial picture.</p>
          <PlaidLinkButton onSuccess={handleSuccess} />
        </div>
      ) : (
        <Dashboard />
      )}
    </div>
  );
}

export default App;

What does this code do?

  • isConnected state starts as false. When false, the app renders the connect screen with the PlaidLinkButton.
  • handleSuccess fires after the token exchange completes. It calls POST /api/transactions/sync to pull the latest transactions, then flips isConnected to true so the app renders the Dashboard instead.
  • The conditional rendering pattern ({!isConnected ? ... : ...}) gives users a clean single-page flow. No routing library needed.
  • Save App.tsx.
  • Start the Vite development server by running this command in your client terminal:
npm run dev
  • Open http://localhost:5173 in your browser. You should see the text "Personal Finance Dashboard" and a button, though it won't be styled yet.

Seeing a compile error?

  • If you see an error about Dashboard or PlaidLinkButton not being found, make sure both files exist inside src/components/ and that the file names match exactly (including the .tsx extension).
  • If you see an error about react-plaid-link not being found, confirm that you installed it in Step 1. Run npm install react-plaid-link in the client directory to fix it.

Help me debug a compile error in my React Plaid app.

Style and test the Plaid Link flow

The app works but looks bare. Let's add styles to the connect screen and then run through the full Plaid Link flow using Sandbox test credentials.

  • Open src/App.css and replace its entire contents with this code:
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.connect-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 60vh;
  text-align: center;
}

.connect-screen h1 {
  font-size: 2.5rem;
  margin-bottom: 0.5rem;
}

.connect-screen p {
  color: #6b7280;
  margin-bottom: 2rem;
  font-size: 1.1rem;
}

.connect-button {
  background: #4f46e5;
  color: white;
  border: none;
  padding: 1rem 2rem;
  font-size: 1.1rem;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.connect-button:hover:not(:disabled) {
  background: #4338ca;
}

.connect-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

What does this code do?

  • .app centers the entire layout with a max width and padding.
  • .connect-screen uses flexbox to vertically and horizontally center the landing content on the page.
  • .connect-button styles the Plaid Link trigger as a prominent indigo button with hover and disabled states.
  • Save App.css and return to your browser.

You should see a centered landing page with a large heading, subtitle text, and a purple "Connect Your Bank Account" button.

  • Click the Connect Your Bank Account button. The Plaid Link modal opens.
  • In the Plaid Link modal, search for any bank. Select one from the list (for example, search for a common bank name).
  • Enter the Sandbox test credentials: username user_good and password pass_good.
  • Select an account and click through to complete the flow.
  • Switch to your server terminal. You should see a log confirming the token exchange succeeded.

Why user_good and pass_good?

Plaid's Sandbox environment provides test credentials that simulate a successful bank connection without touching real financial data. The username user_good and password pass_good work with any Sandbox institution and always succeed.

Plaid Link not opening?

  • Check that your Express server is still running on port 3001. The PlaidLinkButton fetches the link token from it on mount.
  • Open your browser's developer console (**F12** or **Cmd+Option+J**). Look for network errors on the /api/create-link-token request. A CORS or connection refused error means the server isn't reachable.
  • Make sure your .env file in the server directory has valid PLAID_CLIENT_ID and PLAID_SECRET values from your Plaid Dashboard.

Help me debug why Plaid Link won't open in my React app.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file you created or edited in this step.

import { useCallback, useEffect, useState } from 'react';
import { usePlaidLink } from 'react-plaid-link';

interface PlaidLinkButtonProps {
  onSuccess: () => void;
}

function PlaidLinkButton({ onSuccess }: PlaidLinkButtonProps) {
  const [linkToken, setLinkToken] = useState<string | null>(null);

  useEffect(() => {
    async function fetchLinkToken() {
      const response = await fetch('http://localhost:3001/api/create-link-token', {
        method: 'POST',
      });
      const data = await response.json();
      setLinkToken(data.link_token);
    }
    fetchLinkToken();
  }, []);

  const handlePlaidSuccess = useCallback(
    async (publicToken: string) => {
      await fetch('http://localhost:3001/api/exchange-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ public_token: publicToken }),
      });
      onSuccess();
    },
    [onSuccess]
  );

  const { open, ready } = usePlaidLink({
    token: linkToken,
    onSuccess: handlePlaidSuccess,
  });

  return (
    <button onClick={() => open()} disabled={!ready} className="connect-button">
      Connect Your Bank Account
    </button>
  );
}

export default PlaidLinkButton;
import { useState } from 'react';
import PlaidLinkButton from './components/PlaidLinkButton';
import Dashboard from './components/Dashboard';
import './App.css';

function App() {
  const [isConnected, setIsConnected] = useState(false);

  const handleSuccess = async () => {
    await fetch('http://localhost:3001/api/transactions/sync', {
      method: 'POST',
    });
    setIsConnected(true);
  };

  return (
    <div className="app">
      {!isConnected ? (
        <div className="connect-screen">
          <h1>Personal Finance Dashboard</h1>
          <p>Connect your bank account to see your financial picture.</p>
          <PlaidLinkButton onSuccess={handleSuccess} />
        </div>
      ) : (
        <Dashboard />
      )}
    </div>
  );
}

export default App;
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.connect-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 60vh;
  text-align: center;
}

.connect-screen h1 {
  font-size: 2.5rem;
  margin-bottom: 0.5rem;
}

.connect-screen p {
  color: #6b7280;
  margin-bottom: 2rem;
  font-size: 1.1rem;
}

.connect-button {
  background: #4f46e5;
  color: white;
  border: none;
  padding: 1rem 2rem;
  font-size: 1.1rem;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.connect-button:hover:not(:disabled) {
  background: #4338ca;
}

.connect-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

Your frontend can now connect bank accounts through Plaid Link and securely exchange tokens with the backend. Next up, you'll build the endpoints that sync and serve the transaction data those accounts generate.

Sync and Serve Transaction Data

Your frontend can connect to bank accounts and exchange tokens securely. Now the backend needs to actually pull transaction data from those connected accounts and serve it to the dashboard.

Plaid's /transactions/sync endpoint uses cursor-based pagination to fetch transactions incrementally. On the first call it returns all available transactions. On subsequent calls it only returns what changed since your last sync.

In this step, get ready to:
  • Build a transaction sync endpoint with cursor-based pagination.
  • Normalize and serve transaction data through a GET endpoint.
  • Create an accounts endpoint that returns balances.
Create the transaction sync endpoint

Plaid can hold thousands of transactions for a connected account. The /transactions/sync endpoint delivers them in pages of up to 500. Your server needs to loop through all pages until there are no more, collecting every transaction along the way.

  • In server/index.js, find the app.listen call at the bottom of the file.
  • Add the following POST /api/transactions/sync endpoint above the app.listen line:
app.post('/api/transactions/sync', async (req, res) => {
  if (!accessToken) {
    return res.status(400).json({ error: 'No account connected' });
  }

  try {
    let hasMore = true;
    let added = [];

    // Loop through all pages of transactions
    while (hasMore) {
      const response = await plaidClient.transactionsSync({
        access_token: accessToken,
        cursor: transactionsCursor || undefined,
        count: 500,
      });

      added = added.concat(response.data.added);
      transactionsCursor = response.data.next_cursor;
      hasMore = response.data.has_more;
    }

    // Normalize each transaction to a clean shape
    const normalized = added.map((txn) => ({
      id: txn.transaction_id,
      name: txn.name,
      merchantName: txn.merchant_name,
      amount: txn.amount,
      date: txn.date,
      category: txn.personal_finance_category?.primary || 'UNCATEGORIZED',
      accountId: txn.account_id,
    }));

    // Prepend new transactions and sort by date (newest first)
    storedTransactions = [...normalized, ...storedTransactions];
    storedTransactions.sort((a, b) => new Date(b.date) - new Date(a.date));

    res.json({ added: normalized.length, total: storedTransactions.length });
  } catch (error) {
    console.error('Error syncing transactions:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to sync transactions' });
  }
});

What does this code do?

The while (hasMore) loop implements cursor-based pagination. On the first call, transactionsCursor is null, so Plaid returns all available transactions from the beginning. Each response includes a next_cursor your server saves for the next call.

The normalization step extracts only the fields your dashboard needs from Plaid's verbose response. The personal_finance_category.primary field gives you clean category labels like FOOD_AND_DRINK or TRANSPORTATION.

Why cursor-based sync instead of date ranges?

Plaid recommends /transactions/sync over the older /transactions/get endpoint for all new integrations. With cursor-based sync, you never miss a transaction update and you avoid complex reconciliation logic.

Each time you sync, you only receive what changed since your last cursor. This is the same pattern production fintech apps use to keep transaction data fresh without re-downloading everything.

You won't see a change in the browser yet. The next chunk adds a way to serve this data.

Serve transaction data through a GET endpoint

The sync endpoint collects and stores transactions, but the frontend needs a simple way to fetch them. A GET endpoint serves the stored transactions without triggering a new sync.

  • Still in server/index.js, add this endpoint directly below the POST /api/transactions/sync handler you just added:
app.get('/api/transactions', (req, res) => {
  res.json({ transactions: storedTransactions });
});

What does this endpoint do?

This returns whatever transactions have been synced so far, sorted by date (newest first). The frontend will call this on mount to populate the dashboard without triggering a fresh API call to Plaid every time.

You won't see a change yet. The next endpoint adds account balance data.

Create the accounts endpoint

Your dashboard also needs to show account balances. Plaid's /accounts/get endpoint returns cached balance data for all accounts linked to the stored access_token.

  • Add this GET /api/accounts endpoint below the GET /api/transactions handler:
app.get('/api/accounts', async (req, res) => {
  if (!accessToken) {
    return res.status(400).json({ error: 'No account connected' });
  }

  try {
    const response = await plaidClient.accountsGet({
      access_token: accessToken,
    });

    // Extract the fields the dashboard needs
    const accounts = response.data.accounts.map((account) => ({
      id: account.account_id,
      name: account.name,
      type: account.type,
      subtype: account.subtype,
      balanceCurrent: account.balances.current,
      balanceAvailable: account.balances.available,
    }));

    res.json({ accounts });
  } catch (error) {
    console.error('Error fetching accounts:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to fetch accounts' });
  }
});

What does this code do?

The accountsGet method calls Plaid's /accounts/get endpoint, which returns cached account data including balances. It maps each account to a simpler object with balanceCurrent and balanceAvailable so the frontend can display them without parsing Plaid's nested response structure.

  • Save server/index.js.
  • Stop the running server in your terminal with Ctrl+C, then restart it by running:
node index.js
  • Open your browser and refresh the React app.
  • Click Connect Your Bank Account and complete the Plaid Link flow again using the Sandbox credentials (username: user_good, password: pass_good).
  • Check your server terminal. You should see the sync complete without errors.
  • Open a new terminal tab and test the GET endpoints by running:
curl http://localhost:3001/api/transactions

You should see a JSON response with a transactions array containing Sandbox test transactions. Each transaction has name, amount, date, and category fields.

  • Test the accounts endpoint by running:
curl http://localhost:3001/api/accounts

You should see a JSON response with an accounts array showing account names, types, and balance values.

Getting "No account connected" errors?

Because the server stores the access_token in memory, restarting it clears the token. You need to reconnect through Plaid Link in the browser before testing the GET endpoints.

If the sync returns zero transactions, wait a few seconds and try again. Plaid Sandbox may take a moment to generate test transaction history after the initial link.

Help me debug my transaction sync endpoint.

✔️ Awesome, I've got everything!

Great. Double check you've saved server/index.js and that all three new endpoints are responding correctly.

ⓧ I'd like to double check the full code

Here is the complete server/index.js file including all endpoints built so far:

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';

dotenv.config();

const app = express();
app.use(cors());
app.use(express.json());

const configuration = new Configuration({
  basePath: PlaidEnvironments[process.env.PLAID_ENV],
  baseOptions: {
    headers: {
      'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
      'PLAID-SECRET': process.env.PLAID_SECRET,
    },
  },
});

const plaidClient = new PlaidApi(configuration);

let accessToken = null;
let itemId = null;
let transactionsCursor = null;
let storedTransactions = [];

app.post('/api/create-link-token', async (req, res) => {
  try {
    const response = await plaidClient.linkTokenCreate({
      user: { client_user_id: 'user-1' },
      client_name: 'Finance Dashboard',
      products: [Products.Transactions],
      country_codes: [CountryCode.Us],
      language: 'en',
    });
    res.json({ link_token: response.data.link_token });
  } catch (error) {
    console.error('Error creating link token:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to create link token' });
  }
});

app.post('/api/exchange-token', async (req, res) => {
  try {
    const { public_token } = req.body;
    const response = await plaidClient.itemPublicTokenExchange({
      public_token,
    });
    accessToken = response.data.access_token;
    itemId = response.data.item_id;
    res.json({ success: true });
  } catch (error) {
    console.error('Error exchanging token:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to exchange token' });
  }
});

app.post('/api/transactions/sync', async (req, res) => {
  if (!accessToken) {
    return res.status(400).json({ error: 'No account connected' });
  }

  try {
    let hasMore = true;
    let added = [];

    while (hasMore) {
      const response = await plaidClient.transactionsSync({
        access_token: accessToken,
        cursor: transactionsCursor || undefined,
        count: 500,
      });

      added = added.concat(response.data.added);
      transactionsCursor = response.data.next_cursor;
      hasMore = response.data.has_more;
    }

    const normalized = added.map((txn) => ({
      id: txn.transaction_id,
      name: txn.name,
      merchantName: txn.merchant_name,
      amount: txn.amount,
      date: txn.date,
      category: txn.personal_finance_category?.primary || 'UNCATEGORIZED',
      accountId: txn.account_id,
    }));

    storedTransactions = [...normalized, ...storedTransactions];
    storedTransactions.sort((a, b) => new Date(b.date) - new Date(a.date));

    res.json({ added: normalized.length, total: storedTransactions.length });
  } catch (error) {
    console.error('Error syncing transactions:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to sync transactions' });
  }
});

app.get('/api/transactions', (req, res) => {
  res.json({ transactions: storedTransactions });
});

app.get('/api/accounts', async (req, res) => {
  if (!accessToken) {
    return res.status(400).json({ error: 'No account connected' });
  }

  try {
    const response = await plaidClient.accountsGet({
      access_token: accessToken,
    });

    const accounts = response.data.accounts.map((account) => ({
      id: account.account_id,
      name: account.name,
      type: account.type,
      subtype: account.subtype,
      balanceCurrent: account.balances.current,
      balanceAvailable: account.balances.available,
    }));

    res.json({ accounts });
  } catch (error) {
    console.error('Error fetching accounts:', error.response?.data || error.message);
    res.status(500).json({ error: 'Failed to fetch accounts' });
  }
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your backend can now sync, normalize, and serve transaction data plus account balances. Next up, you'll build the React dashboard that turns this raw data into spending charts and transaction lists.

Build the Financial Dashboard

Your backend is fully wired up. It syncs transactions, serves account balances, and normalizes financial data into clean shapes.

Now it's time to build the part users actually see. In this step, you'll create a React dashboard that pulls data from your API and renders it as interactive charts and lists. This is the visualization layer that turns raw financial data into something meaningful.

In this step, get ready to:
  • Create a Dashboard component that fetches account and transaction data on mount.
  • Build a spending-by-category bar chart using Recharts.
  • Build a transactions list and account balances display with proper styling.
Create the Dashboard component with data fetching

The dashboard needs to fetch both transactions and account data from your server as soon as it mounts. It then transforms that data into chart-friendly shapes and renders three visual sections.

  • Create a new file at client/src/components/Dashboard.tsx and paste the following code:
import { useEffect, useState } from 'react';
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  Tooltip,
  CartesianGrid,
  ResponsiveContainer,
} from 'recharts';

interface Transaction {
  id: string;
  name: string;
  merchantName: string | null;
  amount: number;
  date: string;
  category: string;
  accountId: string;
}

interface Account {
  id: string;
  name: string;
  type: string;
  subtype: string;
  balanceCurrent: number | null;
  balanceAvailable: number | null;
}

function Dashboard() {
  const [transactions, setTransactions] = useState<Transaction[]>([]);
  const [accounts, setAccounts] = useState<Account[]>([]);

  useEffect(() => {
    async function fetchData() {
      const [txnRes, acctRes] = await Promise.all([
        fetch('http://localhost:3001/api/transactions'),
        fetch('http://localhost:3001/api/accounts'),
      ]);
      const txnData = await txnRes.json();
      const acctData = await acctRes.json();
      setTransactions(txnData.transactions);
      setAccounts(acctData.accounts);
    }
    fetchData();
  }, []);

  const spendingByCategory = transactions
    .filter((txn) => txn.amount > 0)
    .reduce<Record<string, number>>((acc, txn) => {
      const category = txn.category;
      acc[category] = (acc[category] || 0) + txn.amount;
      return acc;
    }, {});

  const chartData = Object.entries(spendingByCategory)
    .map(([category, amount]) => ({
      category: category.replace(/_/g, ' '),
      amount: Math.round(amount * 100) / 100,
    }))
    .sort((a, b) => b.amount - a.amount)
    .slice(0, 8);

  const recentTransactions = transactions.slice(0, 20);

  return (
    <div className="dashboard">
      <h1>Your Financial Dashboard</h1>

      <section className="accounts-section">
        <h2>Account Balances</h2>
        <div className="accounts-grid">
          {accounts.map((account) => (
            <div key={account.id} className="account-card">
              <h3>{account.name}</h3>
              <p className="account-type">{account.subtype}</p>
              <p className="account-balance">
                ${account.balanceCurrent?.toFixed(2) ?? 'N/A'}
              </p>
            </div>
          ))}
        </div>
      </section>

      <section className="chart-section">
        <h2>Spending by Category</h2>
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={chartData}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="category" angle={-20} textAnchor="end" height={80} />
            <YAxis />
            <Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
            <Bar dataKey="amount" fill="#4f46e5" radius={[4, 4, 0, 0]} />
          </BarChart>
        </ResponsiveContainer>
      </section>

      <section className="transactions-section">
        <h2>Recent Transactions</h2>
        <div className="transactions-list">
          {recentTransactions.map((txn) => (
            <div key={txn.id} className="transaction-row">
              <div className="transaction-info">
                <span className="transaction-name">
                  {txn.merchantName || txn.name}
                </span>
                <span className="transaction-category">{txn.category.replace(/_/g, ' ')}</span>
              </div>
              <div className="transaction-details">
                <span
                  className={`transaction-amount ${txn.amount > 0 ? 'outflow' : 'inflow'}`}
                >
                  {txn.amount > 0 ? '-' : '+'}${Math.abs(txn.amount).toFixed(2)}
                </span>
                <span className="transaction-date">{txn.date}</span>
              </div>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

export default Dashboard;

What does this component do?

This component handles three responsibilities:

  • Data fetching: useEffect calls both /api/transactions and /api/accounts in parallel with Promise.all. This avoids sequential requests that would slow down loading.
  • Data transformation: the spendingByCategory computation filters for outflows (positive amounts in Plaid), groups them by category, and prepares the top 8 for the chart.
  • Rendering: three sections display account balance cards, a Recharts bar chart, and a color-coded transaction list.

Why are outflows positive?

Plaid returns amounts as positive numbers for money leaving the account and negative for money coming in. The dashboard uses this convention to color-code transactions: red for outflows, green for inflows.

  • Save Dashboard.tsx.

Seeing import errors in your editor?

If your editor underlines the recharts import, make sure you installed the recharts package in Step 1. Run npm install recharts from inside the client directory if needed.

help me fix recharts import errors in my Dashboard component

Style the dashboard layout

The Dashboard component renders HTML structure, but without CSS it will look like unstyled text. You need grid layouts for the account cards, spacing between sections, and color-coding for transaction amounts.

  • Open client/src/App.css and add the following styles below the existing .connect-button:disabled block:
.dashboard h1 {
  margin-bottom: 2rem;
}

.accounts-section,
.chart-section,
.transactions-section {
  margin-bottom: 2.5rem;
}

.accounts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1rem;
}

.account-card {
  background: #f9fafb;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 1.5rem;
}

.account-card h3 {
  margin: 0 0 0.25rem;
}

.account-type {
  color: #6b7280;
  font-size: 0.875rem;
  text-transform: capitalize;
  margin: 0 0 0.75rem;
}

.account-balance {
  font-size: 1.5rem;
  font-weight: 700;
  margin: 0;
}

.transactions-list {
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  overflow: hidden;
}

.transaction-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid #f3f4f6;
}

.transaction-row:last-child {
  border-bottom: none;
}

.transaction-info {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.transaction-name {
  font-weight: 500;
}

.transaction-category {
  font-size: 0.75rem;
  color: #6b7280;
  text-transform: capitalize;
}

.transaction-details {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.25rem;
}

.transaction-amount {
  font-weight: 600;
}

.transaction-amount.outflow {
  color: #dc2626;
}

.transaction-amount.inflow {
  color: #16a34a;
}

.transaction-date {
  font-size: 0.75rem;
  color: #6b7280;
}

What do these styles do?

  • accounts-grid uses CSS Grid with auto-fill so account cards wrap responsively based on screen width.
  • transaction-amount.outflow and .inflow apply red and green colors so users can instantly distinguish money going out from money coming in.
  • The transactions-list uses rounded borders and hidden overflow to create a clean card-like container for the transaction rows.
  • Save App.css.

Styles not applying?

Make sure you pasted the new styles after the existing .connect-button:disabled block, not inside it. Each selector should start at the root indentation level of the file.

help me debug why my dashboard CSS styles aren't showing up

Test the full dashboard flow

Your dashboard is complete. Time to see all three sections working together with real Sandbox data.

  • Make sure your server is still running in one terminal (node index.js from the server directory).
  • In a second terminal, start the React dev server from the client directory by running:
npm run dev
  • Open the URL shown in your terminal (usually http://localhost:5173).
  • Click Connect Your Bank Account.
  • Complete the Plaid Link flow using the Sandbox test credentials: username user_good, password pass_good.
  • After connecting, the dashboard should appear with all three sections populated.

You should see account balance cards at the top, a bar chart showing spending grouped by category in the middle, and a list of recent transactions at the bottom with red amounts for outflows and green for inflows.

Dashboard showing empty sections?

Check your browser's developer console for errors. A common issue is the server not running on port 3001 or CORS blocking the requests.

If transactions are empty, the sync may not have completed. Check the server terminal for sync logs confirming transactions were added.

help me debug why my finance dashboard shows empty data after connecting my account

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete Dashboard.tsx:

import { useEffect, useState } from 'react';
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  Tooltip,
  CartesianGrid,
  ResponsiveContainer,
} from 'recharts';

interface Transaction {
  id: string;
  name: string;
  merchantName: string | null;
  amount: number;
  date: string;
  category: string;
  accountId: string;
}

interface Account {
  id: string;
  name: string;
  type: string;
  subtype: string;
  balanceCurrent: number | null;
  balanceAvailable: number | null;
}

function Dashboard() {
  const [transactions, setTransactions] = useState<Transaction[]>([]);
  const [accounts, setAccounts] = useState<Account[]>([]);

  useEffect(() => {
    async function fetchData() {
      const [txnRes, acctRes] = await Promise.all([
        fetch('http://localhost:3001/api/transactions'),
        fetch('http://localhost:3001/api/accounts'),
      ]);
      const txnData = await txnRes.json();
      const acctData = await acctRes.json();
      setTransactions(txnData.transactions);
      setAccounts(acctData.accounts);
    }
    fetchData();
  }, []);

  const spendingByCategory = transactions
    .filter((txn) => txn.amount > 0)
    .reduce<Record<string, number>>((acc, txn) => {
      const category = txn.category;
      acc[category] = (acc[category] || 0) + txn.amount;
      return acc;
    }, {});

  const chartData = Object.entries(spendingByCategory)
    .map(([category, amount]) => ({
      category: category.replace(/_/g, ' '),
      amount: Math.round(amount * 100) / 100,
    }))
    .sort((a, b) => b.amount - a.amount)
    .slice(0, 8);

  const recentTransactions = transactions.slice(0, 20);

  return (
    <div className="dashboard">
      <h1>Your Financial Dashboard</h1>

      <section className="accounts-section">
        <h2>Account Balances</h2>
        <div className="accounts-grid">
          {accounts.map((account) => (
            <div key={account.id} className="account-card">
              <h3>{account.name}</h3>
              <p className="account-type">{account.subtype}</p>
              <p className="account-balance">
                ${account.balanceCurrent?.toFixed(2) ?? 'N/A'}
              </p>
            </div>
          ))}
        </div>
      </section>

      <section className="chart-section">
        <h2>Spending by Category</h2>
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={chartData}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="category" angle={-20} textAnchor="end" height={80} />
            <YAxis />
            <Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
            <Bar dataKey="amount" fill="#4f46e5" radius={[4, 4, 0, 0]} />
          </BarChart>
        </ResponsiveContainer>
      </section>

      <section className="transactions-section">
        <h2>Recent Transactions</h2>
        <div className="transactions-list">
          {recentTransactions.map((txn) => (
            <div key={txn.id} className="transaction-row">
              <div className="transaction-info">
                <span className="transaction-name">
                  {txn.merchantName || txn.name}
                </span>
                <span className="transaction-category">{txn.category.replace(/_/g, ' ')}</span>
              </div>
              <div className="transaction-details">
                <span
                  className={`transaction-amount ${txn.amount > 0 ? 'outflow' : 'inflow'}`}
                >
                  {txn.amount > 0 ? '-' : '+'}${Math.abs(txn.amount).toFixed(2)}
                </span>
                <span className="transaction-date">{txn.date}</span>
              </div>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

export default Dashboard;

And here is the complete App.css:

.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.connect-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 60vh;
  text-align: center;
}

.connect-screen h1 {
  font-size: 2.5rem;
  margin-bottom: 0.5rem;
}

.connect-screen p {
  color: #6b7280;
  margin-bottom: 2rem;
  font-size: 1.1rem;
}

.connect-button {
  background: #4f46e5;
  color: white;
  border: none;
  padding: 1rem 2rem;
  font-size: 1.1rem;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.connect-button:hover:not(:disabled) {
  background: #4338ca;
}

.connect-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.dashboard h1 {
  margin-bottom: 2rem;
}

.accounts-section,
.chart-section,
.transactions-section {
  margin-bottom: 2.5rem;
}

.accounts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1rem;
}

.account-card {
  background: #f9fafb;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 1.5rem;
}

.account-card h3 {
  margin: 0 0 0.25rem;
}

.account-type {
  color: #6b7280;
  font-size: 0.875rem;
  text-transform: capitalize;
  margin: 0 0 0.75rem;
}

.account-balance {
  font-size: 1.5rem;
  font-weight: 700;
  margin: 0;
}

.transactions-list {
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  overflow: hidden;
}

.transaction-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid #f3f4f6;
}

.transaction-row:last-child {
  border-bottom: none;
}

.transaction-info {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.transaction-name {
  font-weight: 500;
}

.transaction-category {
  font-size: 0.75rem;
  color: #6b7280;
  text-transform: capitalize;
}

.transaction-details {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.25rem;
}

.transaction-amount {
  font-weight: 600;
}

.transaction-amount.outflow {
  color: #dc2626;
}

.transaction-amount.inflow {
  color: #16a34a;
}

.transaction-date {
  font-size: 0.75rem;
  color: #6b7280;
}

Your financial dashboard is live. You've connected to real (Sandbox) bank accounts, synced transactions, and visualized the data in an interactive UI. Next up, you'll take on the Secret Mission and build a recurring transaction detector.

Secret mission

Detect Recurring Transactions

Real finance apps like ChatGPT Finances surface upcoming payments by detecting patterns in transaction history. Can you build a recurring transaction detector that identifies subscriptions and regular payments from the raw data?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Your Plaid Sandbox account is free and incurs no charges even if you leave it active.

Decide whether to keep your resources for future development, or delete them entirely.

Resources you used:

  • Local project folder containing server/ and client/ directories.
  • .env file with your Plaid API credentials.
  • Plaid Sandbox developer account (free, no charges).

Keep everything running

No action needed. Choose this if you plan to continue building on this project or want to reference the code later.

  • Your local files stay on your Desktop until you decide to remove them.
  • Your Plaid Sandbox account remains free indefinitely. Plaid may automatically deactivate it after three months of inactivity.
  • Node.js remains installed on your machine for future JavaScript projects.

Pause - I'll come back to this later

Stop running processes to free up terminal sessions and ports, but keep all files intact for when you return.

  • In the terminal running your Express server, press Ctrl+C to stop the server.
  • In the terminal running your Vite dev server, press Ctrl+C to stop the client.
  • Your project files and Plaid account remain intact. When you return, run npm start in the server/ directory and npm run dev in the client/ directory to pick up where you left off.

Delete - I don't want to use this again

Remove all project files and credentials from your machine.

  • Stop both servers by pressing Ctrl+C in each terminal.
  • Delete your project folder from the Desktop by running these commands in a new terminal:

macOS

cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

Windows

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Delete your Plaid Sandbox team. Log in to the Plaid Dashboard, click your name in the lower-left corner, and select Teams. Click the ... button next to your team and select Delete Team. This deactivates your API keys.
  • To delete your Plaid Dashboard login entirely, contact Plaid support at dashboard.plaid.com/support under Account Administration. Note that this is irreversible.

Nice Work!

Nice Work!

You just built a full-stack personal finance dashboard that mirrors the core architecture behind products like ChatGPT Finances.

You've learned how to:

  • Connect financial accounts securely via Plaid Link with a proper token exchange flow that keeps sensitive credentials server-side.
  • Build a resilient transaction sync using cursor-based pagination that handles incremental updates from Plaid's API.
  • Create an interactive React dashboard with spending-by-category visualization using Recharts, account balance cards, and a formatted transaction list.
  • Secret Mission: Built a recurring transaction detector that identifies subscriptions from transaction patterns based on amount variance and interval analysis.

Ready to quiz yourself?