Build a Budget Tracker with Plaid

Connect a simulated bank account and build a spending dashboard with Plaid.

Introduction

30 Second Summary

Every budgeting app you have ever used connects to your bank the same way. Behind the scenes, a service called Plaid handles the secure handshake between the app and your financial institution.

In this project, you will build a personal budget tracker that connects to a simulated bank account, pulls transaction data, and displays a categorized spending dashboard. You will use Plaid's free Sandbox environment, so no real bank data is ever touched.

What You'll Build

A Next.js app that integrates with Plaid's Sandbox to authenticate a bank connection, sync transactions, and visualize spending against budget targets you define.

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

  • A working Plaid Link integration that simulates connecting a real bank account.
  • Server-side API routes for token exchange and transaction syncing using Plaid's cursor-based pagination.
  • A spending dashboard with category breakdowns and color-coded budget comparisons.
  • A month-over-month trend view that highlights categories where your spending is increasing.

Are there any prerequisites?

You will need a free Plaid developer account with Sandbox API keys, Node.js installed on your machine, and familiarity with Next.js, React, and TypeScript. The entire project runs in Plaid's Sandbox at zero cost.

Set Up Your Next.js Project and Plaid SDK

Your budget tracker needs a working project before it can connect to bank accounts or display spending data. This step builds that foundation.

You'll scaffold a Next.js app with TypeScript and Tailwind CSS, install the Plaid SDK, and create a reusable client utility.

In this step, get ready to:
  • Scaffold a new Next.js project.
  • Install the Plaid SDK and configure your Sandbox credentials.
  • Create a reusable Plaid client utility file.
Scaffold your Next.js project
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest budget-tracker --typescript --app --tailwind --eslint --src-dir --no-import-alias

What do these flags do?

  • --typescript enables TypeScript for type-safe code.
  • --app uses the App Router, which is how Next.js handles pages and API routes.
  • --tailwind adds Tailwind CSS for utility-based styling.
  • --src-dir puts your source code inside a src/ folder, keeping the project root clean.
  • --no-import-alias keeps the default @/ import alias that maps to src/.

Pro tip: npx install prompt

If npx asks you to install create-next-app, type y and press Enter to continue.

  • Wait for the scaffolding to finish. You should see a Success! message in the terminal output.

Command not found?

  • Make sure Node.js is installed by running node --version. If it's not recognized, install Node.js from nodejs.org first.
  • If you get a network error, check your internet connection and try again.
  • help me fix my create-next-app error
  • Navigate into your new project folder by running:
cd budget-tracker

Now open the project in VS Code so you can work with the files directly.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the budget-tracker folder on your Desktop, then click Open.

In the VS Code Explorer sidebar, you should see files like package.json, next.config.ts, and a src/ folder.

Install Plaid dependencies and add your credentials

Your project needs two packages to talk to Plaid. You also need to store your API credentials so the server can authenticate requests.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key).
  • Install the Plaid packages by running:
npm install plaid@42 react-plaid-link@4

What are these packages?

  • plaid is Plaid's official Node.js SDK. It provides typed methods for every Plaid API endpoint, like creating link tokens and syncing transactions.
  • react-plaid-link is a React component library that provides the usePlaidLink hook. This hook manages Plaid Link, the drop-in UI where users securely log into their bank.
  • You should see output ending with something like added X packages confirming the install succeeded.

Install failed?

  • Make sure your terminal is inside the budget-tracker folder. Run pwd to check your current directory.
  • If you see permission errors, try running the command again. Do not use sudo with npm install.
  • help me troubleshoot my npm install error

Next, you need your Plaid Sandbox credentials. These let your server authenticate with Plaid's API.

  • Open the Plaid Dashboard in your browser and log in.
  • Click Team Settings in the left sidebar.
  • Click Keys.
  • Copy your client_id and your Sandbox secret. Keep this tab open so you can paste them in a moment.

What is the Sandbox?

Plaid's Sandbox is a free testing environment with simulated bank data. It behaves exactly like Production but uses fake accounts and transactions. You can make unlimited API calls at no cost.

  • In VS Code, create a new file called .env.local in the root of your budget-tracker folder (the same level as package.json).
  • Add the following content to .env.local, replacing the placeholder values with your actual credentials from the Plaid Dashboard:
PLAID_CLIENT_ID=your-client-id-here
PLAID_SECRET=your-sandbox-secret-here
PLAID_ENV=sandbox

What are these environment variables?

  • PLAID_CLIENT_ID identifies your Plaid account. Every API request includes this.
  • PLAID_SECRET authenticates your server. This is environment-specific, so use the Sandbox secret (not Production).
  • PLAID_ENV tells the SDK which Plaid environment to connect to. The sandbox value routes requests to sandbox.plaid.com.
  • Save .env.local.
  • Confirm the file appears in the VS Code Explorer sidebar at the root level, alongside package.json.

Credentials not working later?

  • Make sure the file is named exactly .env.local (with the leading dot). Files without the dot are not loaded by Next.js.
  • Check that there are no spaces around the = signs. The format must be KEY=value with no extra whitespace.
  • Confirm you copied the Sandbox secret, not the Production secret.
  • help me troubleshoot my Plaid credentials
Create the Plaid client utility

Every API route in your app will need to talk to Plaid. Instead of configuring the Plaid SDK in every file, you'll create one shared client that all routes import.

  • In VS Code's Explorer sidebar, right-click the src folder.
  • Select New File.
  • Type lib/plaid.ts and press Enter. This creates both the lib/ folder and the file.
  • Add the following code to plaid.ts:
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';

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

// Export a single Plaid client instance for all API routes to share
export const plaidClient = new PlaidApi(configuration);

What does this code do?

  • Configuration sets up the SDK with a base URL and authentication headers. PlaidEnvironments.sandbox points all requests to Plaid's Sandbox at sandbox.plaid.com.
  • The headers pass your PLAID_CLIENT_ID and PLAID_SECRET from .env.local on every API call. The ! tells TypeScript these values exist at runtime.
  • PlaidApi is the main SDK class. Exporting a single instance means all your API routes share the same authenticated client.
  • Save plaid.ts.

You won't see a visible change yet. This utility file is used by the API routes you'll build in the next step.

Seeing a red underline on the import?

  • Make sure you installed the plaid package in the previous substep. If you skipped it, run npm install plaid@42 in the integrated terminal.
  • Verify that the file is at src/lib/plaid.ts (inside src, not at the project root).
  • help me fix the import error in plaid.ts

Awesome, I've got everything!

Double check you've saved plaid.ts and move on to the next step.

I'd like to double check the full code

import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';

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

export const plaidClient = new PlaidApi(configuration);

Your project skeleton is ready and wired up to Plaid's Sandbox. Next up, you'll build the Plaid Link integration that lets users connect a bank account.

Build the Plaid Link Integration

Your project is set up with the Plaid SDK installed and a client utility ready to make API calls. Now it is time to build the feature that actually connects a user to their bank account.

Plaid uses a three-step token flow to securely link bank accounts. Your server creates a link token, the frontend uses it to open a bank login UI, and then a public token gets exchanged server-side for a permanent access token. In this step, you will build all three pieces and test the full flow.

In this step, get ready to:
  • Create the link token API route that initializes Plaid Link.
  • Build a PlaidLink button component that opens the bank login UI.
  • Create the token exchange route and test the full connection flow.
Create the link token API route

Every Plaid Link session starts with a link token. Your server creates one by calling Plaid's API, and the frontend uses it to open the bank login widget.

  • Create the folder src/app/api/create-link-token/.
  • Create a new file called route.ts inside that folder and add the following code:
import { NextResponse } from 'next/server';
import { plaidClient } from '@/lib/plaid';
import { CountryCode, Products } from 'plaid';

export async function POST() {
  try {
    // Create a link token that the frontend will use to open Plaid Link
    const response = await plaidClient.linkTokenCreate({
      user: { client_user_id: 'user-1' },
      client_name: 'Budget Tracker',
      products: [Products.Transactions],
      country_codes: [CountryCode.Us],
      language: 'en',
    });

    return NextResponse.json({ link_token: response.data.link_token });
  } catch (error: unknown) {
    console.error('Error creating link token:', error);
    return NextResponse.json(
      { error: 'Failed to create link token' },
      { status: 500 }
    );
  }
}

What does this code do?

  • linkTokenCreate() tells Plaid to generate a short-lived token that authorizes a single Link session.
  • Products.Transactions requests access to the user's transaction data. This is the Plaid product your budget tracker needs.
  • client_user_id is a unique identifier for the user. For this demo, a hardcoded value works fine.
  • The route returns the link_token as JSON so the frontend can use it to open Plaid Link.
  • Save src/app/api/create-link-token/route.ts.

You won't see a change yet. The PlaidLink button component will call this route in the next substep.

Seeing red underlines on the imports?

If your editor shows errors on the plaid or @/lib/plaid imports, make sure you installed the plaid package and created the src/lib/plaid.ts client file in Step 1.

Why is my Plaid import failing?

Build the PlaidLink button component

Plaid Link is a drop-in UI that handles bank authentication. Your component fetches the link token from your API, passes it to the usePlaidLink hook, and opens the bank login when the user clicks a button.

  • Create a src/components/ folder inside your src/ directory.
  • Create a new file called PlaidLinkButton.tsx inside src/components/ and add the following code:
'use client';

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

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

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

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

  // When Plaid Link succeeds, send the public_token to our exchange endpoint
  const onSuccess = useCallback<PlaidLinkOnSuccess>(
    async (publicToken) => {
      await fetch('/api/exchange-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ public_token: publicToken }),
      });
      onConnected();
    },
    [onConnected]
  );

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

  return (
    <button
      onClick={() => open()}
      disabled={!ready}
      className="rounded-lg bg-blue-600 px-6 py-3 text-white font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
    >
      Connect Bank Account
    </button>
  );
}

What does this code do?

  • 'use client' marks this as a client component. React hooks like useState and useEffect only work on the client side.
  • The useEffect fetches a link token from your API route as soon as the component mounts. This prepares Plaid Link before the user clicks anything.
  • onSuccess fires after the user successfully logs into their bank. It receives a publicToken and sends it to your server for exchange.
  • usePlaidLink returns open (to launch the Link UI) and ready (a boolean that is true once Link is initialized). The button stays disabled until Link is ready.

Why wrap onSuccess in useCallback?

The usePlaidLink hook recreates the Plaid Link handler whenever its options change. Wrapping onSuccess in useCallback gives it a stable identity, preventing unnecessary re-initializations.

  • Save src/components/PlaidLinkButton.tsx.

You won't see this component yet. You will add it to the page and test the full flow in the next substep.

Red underline on usePlaidLink?

Check that you installed react-plaid-link in Step 1. If the package is installed but TypeScript still complains, try restarting your editor.

Help me fix my react-plaid-link import error.

Create the token exchange route and test the flow

After the user logs in through Plaid Link, Plaid returns a temporary public token. Your server needs to exchange this for a permanent access token, which is what you use for all future API calls like fetching transactions.

Before building the exchange route, you need a place to store the access token. In a production app, you would use a database. For this demo, a simple in-memory store works fine.

  • Create a new file called store.ts inside your src/lib/ folder and add the following code:
// In-memory store for demo purposes. In production, use a database.
let accessToken: string | null = null;
let cursor: string | null = null;

export function getAccessToken() {
  return accessToken;
}

export function setAccessToken(token: string) {
  accessToken = token;
}

export function getCursor() {
  return cursor;
}

export function setCursor(newCursor: string) {
  cursor = newCursor;
}

What does this code do?

This module holds two values in memory: the accessToken (for making Plaid API calls) and the cursor (for tracking your position when syncing transactions). Both are exported through getter/setter functions so your API routes can share them.

  • Save src/lib/store.ts.

You won't see a change yet. The exchange route you create next will use this store.

  • Create the folder src/app/api/exchange-token/.
  • Create a new file called route.ts inside that folder and add the following code:
import { NextResponse } from 'next/server';
import { plaidClient } from '@/lib/plaid';
import { setAccessToken } from '@/lib/store';

export async function POST(request: Request) {
  try {
    // Read the public_token sent from the frontend
    const { public_token } = await request.json();

    // Exchange it for a permanent access_token
    const response = await plaidClient.itemPublicTokenExchange({
      public_token,
    });

    const accessToken = response.data.access_token;
    setAccessToken(accessToken);

    console.log('Access token stored successfully');

    return NextResponse.json({ success: true });
  } catch (error: unknown) {
    console.error('Error exchanging token:', error);
    return NextResponse.json(
      { error: 'Failed to exchange token' },
      { status: 500 }
    );
  }
}

What does this code do?

  • itemPublicTokenExchange() swaps the temporary public token for a permanent access token. This is Plaid's security model. The public token is short-lived and only usable once.
  • setAccessToken() saves the access token in your in-memory store so other API routes (like the transactions route) can use it later.
  • The console.log confirms the exchange worked. You will check for this message in your terminal when you test.
  • Save src/app/api/exchange-token/route.ts.

Now you need to wire the PlaidLink button into your main page so you can test the full flow.

  • Open src/app/page.tsx and replace its entire contents with the following code:
'use client';

import { useState } from 'react';
import PlaidLinkButton from '@/components/PlaidLinkButton';

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="mx-auto max-w-3xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Budget Tracker
        </h1>

        {!isConnected ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Connect your bank account to get started
            </h2>
            <p className="text-gray-500 mb-4">
              Uses Plaid Sandbox with test data. No real bank data is accessed.
            </p>
            <PlaidLinkButton onConnected={() => setIsConnected(true)} />
          </div>
        ) : (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Bank account connected!
            </h2>
          </div>
        )}
      </div>
    </main>
  );
}

What does this code do?

  • isConnected tracks whether the user has linked a bank account. The page shows the connect button when false and a success message when true.
  • PlaidLinkButton receives an onConnected callback that flips isConnected to true after a successful exchange. You will expand this page with transaction syncing and the dashboard in later steps.
  • Save src/app/page.tsx.
  • Start your development server by running:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see a white card with the heading "Connect your bank account to get started" and a blue Connect Bank Account button.

Now test the full Plaid Link flow using Sandbox credentials.

  • Click Connect Bank Account. The Plaid Link modal opens.
  • Select any institution from the list (for example, select the first bank you see).
  • Enter user_good as the username.
  • Enter pass_good as the password.
  • Follow the prompts to complete the connection.

What are user_good and pass_good?

These are Plaid's standard Sandbox test credentials. They simulate a successful bank login without connecting to any real bank. The Sandbox environment is free with unlimited test API calls.

After completing the Plaid Link flow, the page should update to show "Bank account connected!" in the browser.

  • Check the terminal where you ran npm run dev. You should see the message Access token stored successfully logged in the output.

Button stays disabled or nothing happens?

  • If the button stays disabled, check your browser's developer console for errors. A common cause is missing or incorrect PLAID_CLIENT_ID or PLAID_SECRET values in your .env.local file.
  • If you see a network error on the /api/create-link-token call, make sure your Plaid credentials are from the Sandbox environment, not Production.
  • If Plaid Link opens but you do not see Access token stored successfully in your terminal, check that the src/app/api/exchange-token/route.ts file is saved and that it imports from @/lib/store correctly.
  • If you changed your .env.local file after starting the dev server, stop the server with Ctrl+C and restart it with npm run dev. Next.js only reads environment variables at startup.

Help me debug my Plaid Link flow.

Awesome, I've got everything!

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

I'd like to double check the full code

Here is the full code for each file you created in this step.

import { NextResponse } from 'next/server';
import { plaidClient } from '@/lib/plaid';
import { CountryCode, Products } from 'plaid';

export async function POST() {
  try {
    const response = await plaidClient.linkTokenCreate({
      user: { client_user_id: 'user-1' },
      client_name: 'Budget Tracker',
      products: [Products.Transactions],
      country_codes: [CountryCode.Us],
      language: 'en',
    });

    return NextResponse.json({ link_token: response.data.link_token });
  } catch (error: unknown) {
    console.error('Error creating link token:', error);
    return NextResponse.json(
      { error: 'Failed to create link token' },
      { status: 500 }
    );
  }
}
'use client';

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

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

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

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

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

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

  return (
    <button
      onClick={() => open()}
      disabled={!ready}
      className="rounded-lg bg-blue-600 px-6 py-3 text-white font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
    >
      Connect Bank Account
    </button>
  );
}
// In-memory store for demo purposes. In production, use a database.
let accessToken: string | null = null;
let cursor: string | null = null;

export function getAccessToken() {
  return accessToken;
}

export function setAccessToken(token: string) {
  accessToken = token;
}

export function getCursor() {
  return cursor;
}

export function setCursor(newCursor: string) {
  cursor = newCursor;
}
import { NextResponse } from 'next/server';
import { plaidClient } from '@/lib/plaid';
import { setAccessToken } from '@/lib/store';

export async function POST(request: Request) {
  try {
    const { public_token } = await request.json();

    const response = await plaidClient.itemPublicTokenExchange({
      public_token,
    });

    const accessToken = response.data.access_token;
    setAccessToken(accessToken);

    console.log('Access token stored successfully');

    return NextResponse.json({ success: true });
  } catch (error: unknown) {
    console.error('Error exchanging token:', error);
    return NextResponse.json(
      { error: 'Failed to exchange token' },
      { status: 500 }
    );
  }
}
'use client';

import { useState } from 'react';
import PlaidLinkButton from '@/components/PlaidLinkButton';

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="mx-auto max-w-3xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Budget Tracker
        </h1>

        {!isConnected ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Connect your bank account to get started
            </h2>
            <p className="text-gray-500 mb-4">
              Uses Plaid Sandbox with test data. No real bank data is accessed.
            </p>
            <PlaidLinkButton onConnected={() => setIsConnected(true)} />
          </div>
        ) : (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Bank account connected!
            </h2>
          </div>
        )}
      </div>
    </main>
  );
}

Your app can now connect to a simulated bank account and securely store an access token. Next up, you will use that access token to fetch and categorize real transaction data.

Fetch and Sync Transactions

Your app can now connect to a bank account and exchange tokens. The next step is to actually pull financial data from that connected account.

Plaid's /transactions/sync endpoint uses a cursor-based approach to fetch transactions. Instead of requesting data by date range, you track your position with a cursor and ask for whatever is new since you last checked. In this step, you'll build the sync route, wire up a button to trigger it, and create a utility to organize the data by category.

In this step, get ready to:
  • Build a transaction sync API route with cursor-based pagination.
  • Add a sync button and verify test transactions appear in the browser.
  • Create a utility function that groups transactions by spending category.
Build the transaction sync API route

Plaid's /transactions/sync endpoint returns transactions in pages. Each response includes a next_cursor value that you pass back on the next request. You keep looping until has_more is false.

Why /transactions/sync instead of /transactions/get?

Plaid offers two transaction endpoints. /transactions/get is the older approach that fetches by date range. /transactions/sync is the recommended approach for all new integrations. It uses cursor-based pagination and supports incremental updates, telling you which transactions were added, modified, or removed since your last sync.

Your transaction sync route needs access to the cursor position between requests. The store module you created in Step 2 already holds the access token. You'll add cursor tracking functions to it now.

  • Open src/lib/store.ts and add the following cursor tracking functions below your existing access token code:
// Track sync position for incremental transaction fetching
let cursor: string | null = null;

export function getCursor() {
  return cursor;
}

export function setCursor(newCursor: string) {
  cursor = newCursor;
}

What does this code do?

  • cursor stores the position marker from Plaid's last sync response. On the first sync, it starts as null, which tells Plaid to send everything from the beginning.
  • getCursor() and setCursor() let the transactions route read and update the cursor after each sync loop.
  • Save store.ts. You won't see a change yet. The transactions route you build next uses these functions.

Awesome, I've got everything!

Double check you've saved your file before moving on.

I'd like to double check the full code

// In-memory store for demo purposes. In production, use a database.
let accessToken: string | null = null;
let cursor: string | null = null;

export function getAccessToken() {
  return accessToken;
}

export function setAccessToken(token: string) {
  accessToken = token;
}

// Track sync position for incremental transaction fetching
export function getCursor() {
  return cursor;
}

export function setCursor(newCursor: string) {
  cursor = newCursor;
}

Now you can build the route that actually calls Plaid's sync endpoint.

  • Create a new file at src/app/api/transactions/route.ts and add the following code:
import { NextResponse } from 'next/server';
import { plaidClient } from '@/lib/plaid';
import { getAccessToken, getCursor, setCursor } from '@/lib/store';
import { RemovedTransaction, Transaction } from 'plaid';

export async function GET() {
  const accessToken = getAccessToken();

  // Reject requests if no bank account is connected yet
  if (!accessToken) {
    return NextResponse.json(
      { error: 'No access token. Connect a bank account first.' },
      { status: 400 }
    );
  }

  try {
    // Start from the last saved cursor position
    let cursor = getCursor();
    let added: Transaction[] = [];
    let modified: Transaction[] = [];
    let removed: RemovedTransaction[] = [];
    let hasMore = true;

    // Keep fetching pages until Plaid says there are no more
    while (hasMore) {
      const response = await plaidClient.transactionsSync({
        access_token: accessToken,
        cursor: cursor || undefined,
      });

      const data = response.data;

      // Accumulate transactions from each page
      added = added.concat(data.added);
      modified = modified.concat(data.modified);
      removed = removed.concat(data.removed);

      hasMore = data.has_more;
      cursor = data.next_cursor;
    }

    // Save cursor so the next sync only fetches new data
    setCursor(cursor!);

    return NextResponse.json({
      added,
      modified,
      removed,
      total: added.length,
    });
  } catch (error: unknown) {
    console.error('Error syncing transactions:', error);
    return NextResponse.json(
      { error: 'Failed to sync transactions' },
      { status: 500 }
    );
  }
}

What does this code do?

  • The route first checks for a stored access_token. If the learner hasn't connected a bank account yet, it returns a 400 error.
  • The while (hasMore) loop calls transactionsSync repeatedly, accumulating all transactions across pages. Each response brings a batch of added, modified, and removed transactions.
  • After the loop finishes, setCursor(cursor!) saves the final cursor position. The next time you call this route, Plaid only returns transactions that changed since this cursor.
  • Save src/app/api/transactions/route.ts. You won't see a change yet. The next substep adds a button to trigger this route from the frontend.

Awesome, I've got everything!

Double check you've saved your file before moving on.

I'd like to double check the full code

import { NextResponse } from 'next/server';
import { plaidClient } from '@/lib/plaid';
import { getAccessToken, getCursor, setCursor } from '@/lib/store';
import { RemovedTransaction, Transaction } from 'plaid';

export async function GET() {
  const accessToken = getAccessToken();

  if (!accessToken) {
    return NextResponse.json(
      { error: 'No access token. Connect a bank account first.' },
      { status: 400 }
    );
  }

  try {
    let cursor = getCursor();
    let added: Transaction[] = [];
    let modified: Transaction[] = [];
    let removed: RemovedTransaction[] = [];
    let hasMore = true;

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

      const data = response.data;

      added = added.concat(data.added);
      modified = modified.concat(data.modified);
      removed = removed.concat(data.removed);

      hasMore = data.has_more;
      cursor = data.next_cursor;
    }

    setCursor(cursor!);

    return NextResponse.json({
      added,
      modified,
      removed,
      total: added.length,
    });
  } catch (error: unknown) {
    console.error('Error syncing transactions:', error);
    return NextResponse.json(
      { error: 'Failed to sync transactions' },
      { status: 500 }
    );
  }
}
Add a sync button and test transactions

Your transactions API route is ready. Now you need a way to trigger it from the browser and see what comes back.

  • Replace the entire contents of src/app/page.tsx with the following code:
'use client';

import { useState } from 'react';
import PlaidLinkButton from '@/components/PlaidLinkButton';

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Fetch transactions from the sync API route
  async function handleSync() {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/transactions');
      const data = await response.json();

      if (!response.ok) {
        setError(data.error || 'Failed to sync transactions');
        return;
      }

      // Log raw transaction data for inspection
      console.log('Synced transactions:', data.added);
    } catch (err) {
      setError('Failed to fetch transactions. Please try again.');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="mx-auto max-w-3xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Budget Tracker
        </h1>

        {!isConnected ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Connect your bank account to get started
            </h2>
            <p className="text-gray-500 mb-4">
              Uses Plaid Sandbox with test data. No real bank data is accessed.
            </p>
            <PlaidLinkButton onConnected={() => setIsConnected(true)} />
          </div>
        ) : (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Bank account connected!
            </h2>
            <button
              onClick={handleSync}
              disabled={loading}
              className="rounded-lg bg-green-600 px-6 py-3 text-white font-semibold hover:bg-green-700 disabled:opacity-50 transition-colors"
            >
              {loading ? 'Syncing...' : 'Sync Transactions'}
            </button>
            {error && <p className="text-red-500 text-sm">{error}</p>}
          </div>
        )}
      </div>
    </main>
  );
}

What does this code do?

  • The page tracks three states: whether a bank account is connected, whether a sync is in progress, and any error messages.
  • handleSync() calls your /api/transactions route and logs the raw transaction data to the console. You'll replace this console.log with proper data processing in the next step.
  • The JSX conditionally shows either the PlaidLinkButton (when no bank is connected) or a green Sync Transactions button (after connecting).
  • Save page.tsx and refresh your browser at http://localhost:3000.
  • Click Connect Bank Account to open Plaid Link.
  • Select any Sandbox institution and log in with username user_good and password pass_good.
  • After connecting, click the green Sync Transactions button.
  • Open your browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).

You should see an array of transaction objects logged to the console. Each transaction includes fields like name, amount, date, and personal_finance_category with primary and detailed subcategories.

Not seeing any transactions in the console?

  • Check that your dev server is still running. If you stopped it, run npm run dev again from the budget-tracker folder.
  • Make sure you connected a bank account first. If you refreshed the page, the in-memory access token was lost. Connect again before syncing.
  • Look at your terminal for server-side errors. If you see Error syncing transactions, check that your .env.local has the correct PLAID_CLIENT_ID and PLAID_SECRET.

Help me debug why my transaction sync is not returning data.

Awesome, I've got everything!

Double check you've saved your file before moving on.

I'd like to double check the full code

'use client';

import { useState } from 'react';
import PlaidLinkButton from '@/components/PlaidLinkButton';

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSync() {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/transactions');
      const data = await response.json();

      if (!response.ok) {
        setError(data.error || 'Failed to sync transactions');
        return;
      }

      console.log('Synced transactions:', data.added);
    } catch (err) {
      setError('Failed to fetch transactions. Please try again.');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="mx-auto max-w-3xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Budget Tracker
        </h1>

        {!isConnected ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Connect your bank account to get started
            </h2>
            <p className="text-gray-500 mb-4">
              Uses Plaid Sandbox with test data. No real bank data is accessed.
            </p>
            <PlaidLinkButton onConnected={() => setIsConnected(true)} />
          </div>
        ) : (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Bank account connected!
            </h2>
            <button
              onClick={handleSync}
              disabled={loading}
              className="rounded-lg bg-green-600 px-6 py-3 text-white font-semibold hover:bg-green-700 disabled:opacity-50 transition-colors"
            >
              {loading ? 'Syncing...' : 'Sync Transactions'}
            </button>
            {error && <p className="text-red-500 text-sm">{error}</p>}
          </div>
        )}
      </div>
    </main>
  );
}
Create the transaction categorization utility

Plaid's Sandbox returns transactions that are already categorized. Each transaction has a personal_finance_category.primary field with values like FOOD_AND_DRINK or TRANSPORTATION. You need a utility that groups transactions by this category and sums the amounts.

  • Create a new file at src/lib/categorize.ts and add the following code:
// Shape of a transaction from the Plaid API
interface Transaction {
  name: string;
  amount: number;
  date: string;
  personal_finance_category?: {
    primary: string;
    detailed: string;
  } | null;
}

// Shape of the grouped output per category
export interface CategorySummary {
  category: string;
  total: number;
  transactions: Transaction[];
}

export function categorizeTransactions(
  transactions: Transaction[]
): CategorySummary[] {
  // Group transactions by their primary spending category
  const categoryMap = new Map<string, Transaction[]>();

  for (const txn of transactions) {
    const category =
      txn.personal_finance_category?.primary || 'UNCATEGORIZED';
    const existing = categoryMap.get(category) || [];
    existing.push(txn);
    categoryMap.set(category, existing);
  }

  // Build summaries with absolute amounts and sort by highest spend
  const summaries: CategorySummary[] = [];

  for (const [category, txns] of categoryMap) {
    const total = txns.reduce((sum, t) => sum + Math.abs(t.amount), 0);
    summaries.push({ category, total, transactions: txns });
  }

  summaries.sort((a, b) => b.total - a.total);

  return summaries;
}

What does this code do?

  • The Transaction interface defines the shape of transaction data coming from Plaid. The personal_finance_category field is optional because some transactions may not have a category assigned.
  • categorizeTransactions() loops through all transactions and groups them into a Map keyed by the primary category. Transactions without a category fall into UNCATEGORIZED.
  • It uses Math.abs() on amounts because Plaid represents expenses as positive numbers and income as negative numbers. Taking the absolute value keeps the totals consistent for spending analysis.
  • The result is sorted by total spend, so the highest-spending categories appear first.
  • Save categorize.ts. You won't see a visible change yet. The dashboard you build in the next step uses this function to process transaction data before rendering it.

Awesome, I've got everything!

Double check you've saved your file before moving on.

I'd like to double check the full code

interface Transaction {
  name: string;
  amount: number;
  date: string;
  personal_finance_category?: {
    primary: string;
    detailed: string;
  } | null;
}

export interface CategorySummary {
  category: string;
  total: number;
  transactions: Transaction[];
}

export function categorizeTransactions(
  transactions: Transaction[]
): CategorySummary[] {
  const categoryMap = new Map<string, Transaction[]>();

  for (const txn of transactions) {
    const category =
      txn.personal_finance_category?.primary || 'UNCATEGORIZED';
    const existing = categoryMap.get(category) || [];
    existing.push(txn);
    categoryMap.set(category, existing);
  }

  const summaries: CategorySummary[] = [];

  for (const [category, txns] of categoryMap) {
    const total = txns.reduce((sum, t) => sum + Math.abs(t.amount), 0);
    summaries.push({ category, total, transactions: txns });
  }

  summaries.sort((a, b) => b.total - a.total);

  return summaries;
}

You now have the full data pipeline working: connecting to a bank, syncing transactions, and organizing them by category. Next up, you'll build a visual spending dashboard that compares your actual spending against budget targets.

Build the Spending Dashboard

Your transaction data is synced and organized by spending category. Numbers in a JSON response are helpful for debugging, but your users need something visual.

In this step, you'll build a spending dashboard that compares actual spending against budget targets. Each category gets a color-coded progress bar so overspending is impossible to miss.

In this step, get ready to:
  • Define monthly budget targets and a comparison function.
  • Create a Dashboard component with color-coded progress bars.
  • Wire the full flow together on the main page.
Define budget targets and comparison logic

Before you can visualize spending, you need something to compare it against. A budget configuration maps each Plaid spending category to a dollar amount you're comfortable spending per month.

  • Create a new file at src/lib/budgets.ts and add the following budget configuration:
import { CategorySummary } from './categorize';

// Monthly spending limits per category
const MONTHLY_BUDGETS: Record<string, number> = {
  FOOD_AND_DRINK: 500,
  TRANSPORTATION: 200,
  ENTERTAINMENT: 100,
  GENERAL_MERCHANDISE: 300,
  RENT_AND_UTILITIES: 1500,
  TRAVEL: 400,
  PERSONAL_CARE: 100,
  GENERAL_SERVICES: 200,
};

// Fallback budget for categories not listed above
const DEFAULT_BUDGET = 250;

export interface BudgetComparison {
  category: string;
  spent: number;
  budget: number;
  remaining: number;
  percentUsed: number;
}

What does this code do?

  • MONTHLY_BUDGETS maps each Plaid spending category (like FOOD_AND_DRINK) to a dollar limit you'd set per month.
  • DEFAULT_BUDGET is a fallback for any category not explicitly listed. Plaid's Sandbox can return categories you haven't budgeted for.
  • BudgetComparison defines the shape of data your Dashboard component will receive.

Don't save yet. The next chunk adds the comparison function to this file.

  • Add the following function below the code you just wrote in budgets.ts:
export function compareToBudget(
  categories: CategorySummary[]
): BudgetComparison[] {
  return categories.map(({ category, total }) => {
    // Look up the budget for this category, or use the default
    const budget = MONTHLY_BUDGETS[category] || DEFAULT_BUDGET;
    const remaining = budget - total;
    const percentUsed = Math.round((total / budget) * 100);

    return {
      category,
      spent: Math.round(total * 100) / 100,
      budget,
      remaining: Math.round(remaining * 100) / 100,
      percentUsed,
    };
  });
}

What does this code do?

  • compareToBudget takes the categorized transaction data from the previous step and compares each category's actual spending to its budget target.
  • It calculates remaining (how much budget is left) and percentUsed (what percentage of the budget has been spent).
  • Amounts are rounded to two decimal places to avoid floating-point display issues.
  • Save budgets.ts.

You won't see a change yet. The Dashboard component in the next substep uses this function.

Awesome, I've got everything!

Great. Double check you've saved budgets.ts before moving on.

I'd like to double check the full code

import { CategorySummary } from './categorize';

const MONTHLY_BUDGETS: Record<string, number> = {
  FOOD_AND_DRINK: 500,
  TRANSPORTATION: 200,
  ENTERTAINMENT: 100,
  GENERAL_MERCHANDISE: 300,
  RENT_AND_UTILITIES: 1500,
  TRAVEL: 400,
  PERSONAL_CARE: 100,
  GENERAL_SERVICES: 200,
};

const DEFAULT_BUDGET = 250;

export interface BudgetComparison {
  category: string;
  spent: number;
  budget: number;
  remaining: number;
  percentUsed: number;
}

export function compareToBudget(
  categories: CategorySummary[]
): BudgetComparison[] {
  return categories.map(({ category, total }) => {
    const budget = MONTHLY_BUDGETS[category] || DEFAULT_BUDGET;
    const remaining = budget - total;
    const percentUsed = Math.round((total / budget) * 100);

    return {
      category,
      spent: Math.round(total * 100) / 100,
      budget,
      remaining: Math.round(remaining * 100) / 100,
      percentUsed,
    };
  });
}
Create the Dashboard component

The Dashboard component turns budget comparison data into a visual layout. Each spending category gets a progress bar that shifts color as spending approaches or exceeds the target.

  • Create a new file at src/components/Dashboard.tsx and add the following setup code:
'use client';

import { BudgetComparison } from '@/lib/budgets';

interface DashboardProps {
  budgetData: BudgetComparison[];
}

// Convert SCREAMING_SNAKE_CASE category names to Title Case
function formatCategory(category: string): string {
  return category
    .replace(/_/g, ' ')
    .toLowerCase()
    .replace(/\b\w/g, (c) => c.toUpperCase());
}

// Return a Tailwind color class based on budget usage
function getBarColor(percentUsed: number): string {
  if (percentUsed > 100) return 'bg-red-500';
  if (percentUsed >= 75) return 'bg-yellow-500';
  return 'bg-green-500';
}

What does this code do?

  • formatCategory converts Plaid's FOOD_AND_DRINK-style category names into readable "Food And Drink" format.
  • getBarColor picks a Tailwind CSS color class based on how much of the budget has been used. Green means under 75%, yellow means 75-100%, and red means over budget.

Don't save yet. The next chunk adds the main Dashboard component to this file.

  • Add the following Dashboard component below the code you just wrote in Dashboard.tsx:
export default function Dashboard({ budgetData }: DashboardProps) {
  // Calculate totals across all categories
  const totalSpent = budgetData.reduce((sum, item) => sum + item.spent, 0);
  const totalBudget = budgetData.reduce((sum, item) => sum + item.budget, 0);
  const totalRemaining = totalBudget - totalSpent;

  return (
    <div className="space-y-6">
      {/* Summary cards showing overall budget status */}
      <div className="grid grid-cols-3 gap-4">
        <div className="rounded-lg bg-white p-4 shadow">
          <p className="text-sm text-gray-500">Total Spent</p>
          <p className="text-2xl font-bold text-gray-900">
            ${totalSpent.toFixed(2)}
          </p>
        </div>
        <div className="rounded-lg bg-white p-4 shadow">
          <p className="text-sm text-gray-500">Total Budget</p>
          <p className="text-2xl font-bold text-gray-900">
            ${totalBudget.toFixed(2)}
          </p>
        </div>
        <div className="rounded-lg bg-white p-4 shadow">
          <p className="text-sm text-gray-500">Remaining</p>
          <p
            className={`text-2xl font-bold ${totalRemaining >= 0 ? 'text-green-600' : 'text-red-600'}`}
          >
            ${totalRemaining.toFixed(2)}
          </p>
        </div>
      </div>

      {/* Per-category progress bars */}
      <div className="space-y-4">
        {budgetData.map((item) => (
          <div key={item.category} className="rounded-lg bg-white p-4 shadow">
            <div className="flex justify-between mb-2">
              <span className="font-medium text-gray-900">
                {formatCategory(item.category)}
              </span>
              <span className="text-sm text-gray-500">
                ${item.spent.toFixed(2)} / ${item.budget.toFixed(2)}
              </span>
            </div>
            <div className="h-3 w-full rounded-full bg-gray-200">
              <div
                className={`h-3 rounded-full ${getBarColor(item.percentUsed)} transition-all`}
                style={{ width: `${Math.min(item.percentUsed, 100)}%` }}
              />
            </div>
            <p className="mt-1 text-xs text-gray-400">
              {item.percentUsed}% of budget used
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

What does this code do?

  • The three summary cards at the top show total spent, total budget, and remaining budget across all categories. The remaining amount turns red if you're over budget.
  • Each category row displays its name, the spent vs. budget amounts, and a progress bar that fills proportionally.
  • The bar color shifts from green to yellow to red as spending approaches or passes the budget target.
  • Save Dashboard.tsx.

You won't see a change yet. The next substep wires this component into the main page.

Seeing a TypeScript error in your editor?

  • Make sure src/lib/budgets.ts is saved. The import @/lib/budgets depends on that file existing with the exported BudgetComparison interface.
  • Check that your categorize.ts file from the previous step exports the CategorySummary interface. The budget module imports it.
  • Why is my Dashboard component showing a TypeScript error?

Awesome, I've got everything!

Great. Double check you've saved Dashboard.tsx before moving on.

I'd like to double check the full code

'use client';

import { BudgetComparison } from '@/lib/budgets';

interface DashboardProps {
  budgetData: BudgetComparison[];
}

function formatCategory(category: string): string {
  return category
    .replace(/_/g, ' ')
    .toLowerCase()
    .replace(/\b\w/g, (c) => c.toUpperCase());
}

function getBarColor(percentUsed: number): string {
  if (percentUsed > 100) return 'bg-red-500';
  if (percentUsed >= 75) return 'bg-yellow-500';
  return 'bg-green-500';
}

export default function Dashboard({ budgetData }: DashboardProps) {
  const totalSpent = budgetData.reduce((sum, item) => sum + item.spent, 0);
  const totalBudget = budgetData.reduce((sum, item) => sum + item.budget, 0);
  const totalRemaining = totalBudget - totalSpent;

  return (
    <div className="space-y-6">
      <div className="grid grid-cols-3 gap-4">
        <div className="rounded-lg bg-white p-4 shadow">
          <p className="text-sm text-gray-500">Total Spent</p>
          <p className="text-2xl font-bold text-gray-900">
            ${totalSpent.toFixed(2)}
          </p>
        </div>
        <div className="rounded-lg bg-white p-4 shadow">
          <p className="text-sm text-gray-500">Total Budget</p>
          <p className="text-2xl font-bold text-gray-900">
            ${totalBudget.toFixed(2)}
          </p>
        </div>
        <div className="rounded-lg bg-white p-4 shadow">
          <p className="text-sm text-gray-500">Remaining</p>
          <p
            className={`text-2xl font-bold ${totalRemaining >= 0 ? 'text-green-600' : 'text-red-600'}`}
          >
            ${totalRemaining.toFixed(2)}
          </p>
        </div>
      </div>

      <div className="space-y-4">
        {budgetData.map((item) => (
          <div key={item.category} className="rounded-lg bg-white p-4 shadow">
            <div className="flex justify-between mb-2">
              <span className="font-medium text-gray-900">
                {formatCategory(item.category)}
              </span>
              <span className="text-sm text-gray-500">
                ${item.spent.toFixed(2)} / ${item.budget.toFixed(2)}
              </span>
            </div>
            <div className="h-3 w-full rounded-full bg-gray-200">
              <div
                className={`h-3 rounded-full ${getBarColor(item.percentUsed)} transition-all`}
                style={{ width: `${Math.min(item.percentUsed, 100)}%` }}
              />
            </div>
            <p className="mt-1 text-xs text-gray-400">
              {item.percentUsed}% of budget used
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}
Wire the dashboard into the main page

The main page needs to manage three states: no bank connected, connected but not yet synced, and the full dashboard view. You'll update page.tsx to import your new components and orchestrate the full flow.

  • Replace the entire contents of src/app/page.tsx with the following code:
'use client';

import { useState } from 'react';
import PlaidLinkButton from '@/components/PlaidLinkButton';
import Dashboard from '@/components/Dashboard';
import { categorizeTransactions } from '@/lib/categorize';
import { compareToBudget, BudgetComparison } from '@/lib/budgets';

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);
  const [budgetData, setBudgetData] = useState<BudgetComparison[] | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSync() {
    setLoading(true);
    setError(null);

    try {
      // Fetch transactions from your API route
      const response = await fetch('/api/transactions');
      const data = await response.json();

      if (!response.ok) {
        setError(data.error || 'Failed to sync transactions');
        return;
      }

      // Categorize transactions and compare to budget targets
      const categories = categorizeTransactions(data.added);
      const comparisons = compareToBudget(categories);
      setBudgetData(comparisons);
    } catch (err) {
      setError('Failed to fetch transactions. Please try again.');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="mx-auto max-w-3xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Budget Tracker
        </h1>

        {/* State 1: No bank account connected yet */}
        {!isConnected ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Connect your bank account to get started
            </h2>
            <p className="text-gray-500 mb-4">
              Uses Plaid Sandbox with test data. No real bank data is accessed.
            </p>
            <PlaidLinkButton onConnected={() => setIsConnected(true)} />
          </div>
        ) : !budgetData ? (
          // State 2: Connected but transactions not synced yet
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Bank account connected!
            </h2>
            <button
              onClick={handleSync}
              disabled={loading}
              className="rounded-lg bg-green-600 px-6 py-3 text-white font-semibold hover:bg-green-700 disabled:opacity-50 transition-colors"
            >
              {loading ? 'Syncing...' : 'Sync Transactions'}
            </button>
            {error && <p className="text-red-500 text-sm">{error}</p>}
          </div>
        ) : (
          // State 3: Full dashboard view
          <Dashboard budgetData={budgetData} />
        )}
      </div>
    </main>
  );
}

What does this code do?

  • The page manages three visual states using React state. First, it shows the PlaidLinkButton for connecting a bank. After connecting, it shows the Sync Transactions button. After syncing, it renders the full Dashboard.
  • handleSync fetches transactions from your API route, pipes them through categorizeTransactions, then through compareToBudget. This transforms raw Plaid data into the budget comparison format your Dashboard expects.
  • Error handling catches both network failures and API errors, displaying messages inline below the sync button.
  • Save page.tsx.
  • If your dev server isn't already running, start it by running:
npm run dev
  • Open http://localhost:3000 in your browser. You should see a white card with the heading "Connect your bank account to get started" and a blue Connect Bank Account button.
  • Click Connect Bank Account. The Plaid Link modal opens.
  • Select any Sandbox institution from the list.
  • Log in with the test credentials: username user_good and password pass_good.
  • Complete the Link flow. The page should update to show "Bank account connected!" with a green Sync Transactions button.
  • Click Sync Transactions. After a moment, your spending dashboard should appear with summary cards at the top and color-coded progress bars for each category.

Dashboard not showing up after syncing?

  • Check your browser's developer console for errors. A common issue is a missing or misspelled import path in page.tsx.
  • Make sure all four files are saved: budgets.ts, categorize.ts, Dashboard.tsx, and page.tsx.
  • If you see "No access token" in the console, your server may have restarted and lost the in-memory token. Click Connect Bank Account again to re-link.
  • Why isn't my spending dashboard rendering after I sync transactions?

Why does re-linking happen after a server restart?

Your access token is stored in an in-memory variable inside store.ts. When the Next.js dev server restarts (after editing server files, for example), that variable resets to null. A production app would store this token in a database to persist it across restarts.

Awesome, I've got everything!

Great. Double check you've saved page.tsx before moving on.

I'd like to double check the full code

'use client';

import { useState } from 'react';
import PlaidLinkButton from '@/components/PlaidLinkButton';
import Dashboard from '@/components/Dashboard';
import { categorizeTransactions } from '@/lib/categorize';
import { compareToBudget, BudgetComparison } from '@/lib/budgets';

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);
  const [budgetData, setBudgetData] = useState<BudgetComparison[] | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSync() {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/transactions');
      const data = await response.json();

      if (!response.ok) {
        setError(data.error || 'Failed to sync transactions');
        return;
      }

      const categories = categorizeTransactions(data.added);
      const comparisons = compareToBudget(categories);
      setBudgetData(comparisons);
    } catch (err) {
      setError('Failed to fetch transactions. Please try again.');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="mx-auto max-w-3xl">
        <h1 className="mb-8 text-3xl font-bold text-gray-900">
          Budget Tracker
        </h1>

        {!isConnected ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Connect your bank account to get started
            </h2>
            <p className="text-gray-500 mb-4">
              Uses Plaid Sandbox with test data. No real bank data is accessed.
            </p>
            <PlaidLinkButton onConnected={() => setIsConnected(true)} />
          </div>
        ) : !budgetData ? (
          <div className="flex flex-col items-center gap-4 rounded-lg bg-white p-12 shadow">
            <h2 className="text-xl font-semibold text-gray-700">
              Bank account connected!
            </h2>
            <button
              onClick={handleSync}
              disabled={loading}
              className="rounded-lg bg-green-600 px-6 py-3 text-white font-semibold hover:bg-green-700 disabled:opacity-50 transition-colors"
            >
              {loading ? 'Syncing...' : 'Sync Transactions'}
            </button>
            {error && <p className="text-red-500 text-sm">{error}</p>}
          </div>
        ) : (
          <Dashboard budgetData={budgetData} />
        )}
      </div>
    </main>
  );
}

Secret mission

Month-Over-Month Spending Trends

Your budget dashboard shows where you stand right now. But what if you could also see how your spending is changing over time? Build a trend view that compares spending across months and flags the categories where your spending is climbing.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses Plaid's free Sandbox environment, so there are no ongoing costs.

Resources you used:

  • Next.js project directory (budget-tracker).
  • Environment file (.env.local) with Plaid Sandbox credentials.
  • Plaid developer account and Sandbox API keys.
  • Node modules installed via npm install.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project with additional Plaid features.

  • Your project files and Plaid credentials stay as they are.
  • Plaid Sandbox is free with unlimited API calls, so there is no cost to keeping your credentials active.

Pause - I'll come back to this later

Stop the development server to free up memory while keeping all your project files intact.

  • Press Ctrl + C in your terminal to stop the Next.js development server.
  • Your project files, .env.local, and node_modules will remain on disk.
  • To resume later, navigate into the budget-tracker directory and run npm run dev.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Press Ctrl + C to stop the development server if it is still running.
  • Delete the entire budget-tracker project directory from your file system.
  • Your Plaid Sandbox API keys can stay in the Plaid Dashboard. They are free and do not expire.
  • If you want to fully clean up, go to the Plaid Dashboard and delete the team under Team Settings.

Mission accomplished

That's a Wrap!

You just built a personal budget tracker powered by Plaid. This is the same integration pattern that fintech apps like Venmo, Mint, and Robinhood use to connect to real bank accounts and pull financial data.

Here's what you learned:

  • Integrated Plaid Link into a Next.js app to simulate connecting a bank account using Plaid's drop-in authentication UI.
  • Implemented the full Plaid token exchange flow, moving from a link_token to a public_token to a server-side access_token for secure API access.
  • Built a spending dashboard that syncs transactions with cursor-based pagination, categorizes spending, and compares actuals against budget targets with color-coded progress bars.
  • Added a month-over-month spending trend view that compares categories across months and highlights where spending increased significantly.

Ready to quiz yourself?