Featured
Building a Personal Finance Dashboard with Next.js, Plaid, and Email Notifications
NOTE: I open sourced this on github.
As a software engineer who loves automation and data visualization, I’ve always wanted a single dashboard to track all my financial accounts. While there are many great services out there like Mint or Personal Capital, I wanted something that I could fully control and customize. This led me to build my own personal finance dashboard using Next.js and the Plaid API.
This project is designed to run locally on your machine, which allows you to maintain full control over your sensitive financial data. By keeping it local, we avoid the complex security and encryption requirements that would be necessary for a public deployment. This design choice lets us focus on functionality while keeping your financial data private and secure.
In this post, I’ll walk through how I built this application, the technical decisions I made, and how you can build something similar.
Project Overview
The dashboard provides:
- Real-time connection to bank accounts using Plaid
- Historical balance tracking
- Daily email updates of balance changes
- Visual representations of your financial data
- Support for multiple financial institutions
Tech Stack
Here’s what I used and why:
- Next.js: For its excellent developer experience, API routes, and server-side rendering capabilities
- Prisma: For type-safe database operations and easy schema management
- Plaid API: For secure bank account connections
- TailwindCSS: For rapid UI development
- Chart.js: For data visualization
- SQLite: For simple, file-based data storage without needing to set up a database server
- Node Mailer: For sending email notifications using any SMTP server
Setting Up the Project
First, I created a new Next.js project with the App Router:
npx create-next-app@latest personal-finance-dashboard --typescript --tailwind --app
Database Setup
I chose SQLite with Prisma for its simplicity and portability. Since this is a local application, SQLite is perfect — it’s fast, reliable, and doesn’t require any additional server setup. Here’s my schema:
// prisma/schema.prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}model PlaidItem {
id String @id @default(cuid())
itemId String @unique
accessToken String
institutionId String
institutionName String?
institutionLogo String?
accounts Account[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}model Account {
id String @id @default(cuid())
plaidId String @unique
name String
type String
subtype String?
mask String?
plaidItem PlaidItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
itemId String
balances AccountBalance[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}model AccountBalance {
id String @id @default(cuid())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
accountId String
current Float
available Float?
limit Float?
date DateTime @default(now()) @@index([date])
}
Email Configuration
The application can send daily balance updates via email. You can configure it to use any SMTP server by updating the following environment variables:
EMAIL_HOST=your-smtp-server.com
EMAIL_PORT=587
EMAIL_USER=your-username
EMAIL_PASSWORD=your-password
EMAIL_FROM=your-email@domain.com
Integrating with Plaid
The Plaid integration involves three main parts:
- Creating a Link token
- Handling the OAuth flow
- Fetching account data
Here’s how I implemented the Link token creation:
// src/app/api/plaid/create-link-token/route.ts
import { NextResponse } from "next/server";
import { plaidClient } from "@/lib/plaid";
import { CountryCode, Products } from "plaid";
export async function POST() {
try {
const request = {
user: { client_user_id: "user-id" },
client_name: "Personal Finance Dashboard",
products: [Products.Transactions],
country_codes: [CountryCode.Us],
language: "en",
}; const response = await plaidClient.linkTokenCreate(request);
return NextResponse.json(response.data);
} catch (error) {
console.error("Error creating link token:", error);
return NextResponse.json(
{ error: "Failed to create link token" },
{ status: 500 }
);
}
}
And here’s how I handle the token exchange:
// src/app/api/plaid/exchange-token/route.ts
export async function POST(request: Request) {
try {
const { public_token } = await request.json();
const exchangeResponse = await plaidClient.itemPublicTokenExchange({
public_token,
});
const { access_token, item_id } = exchangeResponse.data;
// Get institution details
const itemResponse = await plaidClient.itemGet({
access_token,
}); // Save to database
const plaidItem = await prisma.plaidItem.create({
data: {
itemId: item_id,
accessToken: access_token,
institutionId: itemResponse.data.item.institution_id!,
},
}); return NextResponse.json({ success: true });
} catch (error) {
console.error("Error exchanging token:", error);
return NextResponse.json(
{ error: "Failed to exchange token" },
{ status: 500 }
);
}
}
Building the Dashboard UI
For the dashboard, I created several components to display financial data. Here’s the main chart component:
// src/components/FinancialGroupChart.tsx
"use client";
import { Pie } from "react-chartjs-2";
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";ChartJS.register(ArcElement, Tooltip, Legend);interface Account {
id: string;
type: string;
subtype: string | null;
balance: {
current: number;
};
}const financialGroups = {
Assets: {
color: "rgba(16, 185, 129, 0.7)",
types: ["depository"],
},
Investments: {
color: "rgba(139, 92, 246, 0.7)",
types: ["investment", "brokerage"],
},
Liabilities: {
color: "rgba(239, 68, 68, 0.7)",
types: ["credit", "loan"],
},
};export function FinancialGroupChart({ accounts }: { accounts: Account[] }) {
const groupData = accounts.reduce((acc, account) => {
const group = getFinancialGroup(account.type, account.subtype);
if (!acc[group]) acc[group] = 0;
acc[group] += account.balance.current;
return acc;
}, {} as Record<string, number>); const chartData = {
labels: Object.keys(groupData),
datasets: [{
data: Object.values(groupData),
backgroundColor: Object.keys(groupData).map(
group => financialGroups[group as keyof typeof financialGroups]?.color
),
}],
}; return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-lg font-semibold mb-4">Balance by Financial Group</h2>
<Pie data={chartData} />
</div>
);
}
Implementing Daily Updates
One of the key features is the daily balance update script. Here’s how I implemented it:
// scripts/refresh-data.ts
async function refreshBalances(): Promise<{
changes: InstitutionChange[];
totalChange: number;
}> {
const items = await prisma.plaidItem.findMany({
include: { accounts: true },
});
const changes: InstitutionChange[] = [];
let totalChange = 0; for (const item of items) {
try {
const response = await plaidClient.accountsBalanceGet({
access_token: item.accessToken,
options: {
min_last_updated_datetime: new Date(
Date.now() - 24 * 60 * 60 * 1000
).toISOString(),
},
}); for (const account of response.data.accounts) {
const existingAccount = await prisma.account.findUnique({
where: { plaidId: account.account_id },
select: {
id: true,
balances: {
orderBy: { date: "desc" },
take: 1,
},
},
}); if (existingAccount) {
const previousBalance = existingAccount.balances[0]?.current || 0;
const currentBalance = account.balances.current || 0;
const change = currentBalance - previousBalance; if (Math.abs(change) > 0.01) {
// Track significant changes
changes.push({
name: account.name,
previousBalance,
currentBalance,
change,
});
totalChange += change;
}
}
}
} catch (error) {
console.error(`Error refreshing balances: ${error}`);
}
} return { changes, totalChange };
}
Challenges and Solutions
1. Handling Plaid Token Expiration
Plaid access tokens can expire or become invalid. I implemented a token refresh mechanism:
async function handlePlaidError(error: PlaidError, item: PlaidItem) {
if (error.error_code === "INVALID_ACCESS_TOKEN") {
// Notify user to reconnect their account
await sendNotification({
type: "RECONNECT_REQUIRED",
institutionName: item.institutionName,
});
}
}
2. Rate Limiting
To avoid hitting Plaid’s rate limits, I implemented exponential backoff:
async function withRetry<T>(
fn: () => Promise<T>,
retries = 3,
delay = 1000
): Promise<T> {
try {
return await fn();
} catch (error) {
if (retries === 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return withRetry(fn, retries - 1, delay * 2);
}
}
3. Email Formatting
Creating responsive emails was challenging. I used MJML for email templates:
const emailTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="20px" color="#333">
Financial Update
</mj-text>
<mj-text>
Total Change: ${formatCurrency(totalChange)}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
`;
Security Considerations
This application is designed to run locally on your machine, which provides several security benefits:
- Your Plaid credentials and financial data stay on your computer
- No need to implement complex user authentication
- No exposure to web-based attacks
- Direct control over your data and its backup
If you decide to modify this for deployment, you would need to implement additional security measures:
- Secure credential storage
- Data encryption at rest
- User authentication and authorization
- Rate limiting and other API protections
- Compliance with financial data regulations
Conclusion
Building this dashboard has been a great learning experience. It combines several modern web technologies and provides practical utility while maintaining privacy by running locally. The source code is available on GitHub, and I welcome contributions and suggestions for improvements.
Some key takeaways:
- Next.js App Router provides a great developer experience
- Plaid’s API is well-documented but requires careful error handling
- Type safety with TypeScript and Prisma is invaluable
- SQLite is perfect for local applications
- Running locally simplifies security considerations
Feel free to check out the GitHub repository and let me know what you think!