DEV Community

Cover image for Building a Full-Stack Finance Tracker: My MERN Journey (Real Problems & Fixes) ๐Ÿ’ฐ
SoftwareDev
SoftwareDev

Posted on

Building a Full-Stack Finance Tracker: My MERN Journey (Real Problems & Fixes) ๐Ÿ’ฐ

Ever wondered what it's really like to build a production-ready MERN stack app? Spoiler: It's not as smooth as the tutorials make it look! ๐Ÿ˜…

I spent 2 months building a personal finance tracker, and I'm sharing the real challenges I facedโ€”especially with authentication, deployment, and dark modeโ€”along with solutions that actually worked.

Live Demo: Finance Tracker

โšก TL;DR

  • Built a production-ready MERN finance tracker
  • Solved real issues with JWT expiration, dark mode flicker, CORS, and recurring jobs
  • Deployed using Vercel + Render + MongoDB Atlas
  • Focused on real-world problems tutorials donโ€™t cover

๐ŸŽฏ What I Built

A full-featured finance tracker with:

  • ๐Ÿ’ฑ Multi-currency support with real-time conversion
  • ๐Ÿ“Š Interactive analytics (charts, breakdowns, trends)
  • ๐Ÿ’ฐ Smart budgets with alert thresholds
  • ๐ŸŽฏ Goal tracking with milestones
  • ๐Ÿ”„ Recurring transactions (salary, rent, subscriptions)
  • ๐ŸŒ“ Dark/Light mode with persistence
  • ๐Ÿ” Secure JWT authentication

Tech Stack:

  • Frontend: React 18, Vite, Tailwind CSS, Recharts
  • Backend: Node.js, Express.js, MongoDB Atlas
  • Deployment: Vercel (frontend) + Render (backend)

๐Ÿ”ฅ Challenge #1: Authentication That Actually Works

The Problem

Users were getting randomly logged out, expired tokens crashed the app, and I had no idea why.

The Solution

I built a multi-layered auth system that handles token lifecycle properly:

1. Smart Token Validation

export const isTokenExpired = (token) => {
  if (!token) return true;
  try {
    const payload = JSON.parse(atob(token.split(".")[1]));
    return payload.exp < Date.now() / 1000;
  } catch {
    return true;
  }
};
Enter fullscreen mode Exit fullscreen mode

2. Axios Interceptor for Auto-Logout

api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.clear();
      window.location.href = "/login";
    }
    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

3. Periodic Token Checks

// Check every 5 minutes
useEffect(() => {
  const interval = setInterval(() => {
    const token = localStorage.getItem("token");
    if (token && isTokenExpired(token)) {
      logout();
    }
  }, 5 * 60 * 1000);
  return () => clearInterval(interval);
}, []);
Enter fullscreen mode Exit fullscreen mode

Key Takeaway: Never trust the client! Always validate tokens on both ends and handle expiration gracefully.


๐ŸŒ“ Challenge #2: Dark Mode Without the Flash

The Problem

The theme would flash white on page load, wouldn't persist, and sometimes ignored system preferences.

The Solution

A ThemeContext that handles everything properly:

export function ThemeProvider({ children }) {
  const [isDarkTheme, setIsDarkTheme] = useState(() => {
    const saved = localStorage.getItem("theme");
    return saved === "dark" || 
      (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches);
  });

  useEffect(() => {
    if (isDarkTheme) {
      document.documentElement.classList.add("dark");
      document.documentElement.setAttribute("data-theme", "dark");
    } else {
      document.documentElement.classList.remove("dark");
      document.documentElement.setAttribute("data-theme", "light");
    }
    localStorage.setItem("theme", isDarkTheme ? "dark" : "light");
  }, [isDarkTheme]);

  return (
    <ThemeContext.Provider value={{ isDarkTheme, toggleTheme: () => setIsDarkTheme(!isDarkTheme) }}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Magic:

  1. Initialize from localStorage FIRST, then system preferences
  2. Apply to <html> element before first render
  3. Use both dark class (Tailwind) and data-theme attribute

๐Ÿ’ฑ Challenge #3: Multi-Currency Support

The Problem

Users wanted to track expenses in different currencies but view everything in their preferred currency.

The Solution

On-demand conversion that only runs when needed:

const convertedTransactions = await Promise.all(
  transactions.map(async (t) => {
    if (t.currency !== preferredCurrency) {
      try {
        const response = await axios.get(
          `https://api.exchangerate-api.com/v4/latest/${t.currency}`
        );
        const rate = response.data.rates[preferredCurrency];
        return {
          ...t._doc,
          amount: t.amount * rate,
          currency: preferredCurrency,
          originalAmount: t.amount, // Keep original!
          originalCurrency: t.currency,
        };
      } catch (error) {
        return t; // Fallback to original
      }
    }
    return t;
  })
);
Enter fullscreen mode Exit fullscreen mode

Optimization Tips:

  • Skip transactions already in preferred currency
  • Use Promise.all() for parallel processing
  • Always keep original values for reference
  • Graceful fallback if API fails

Future improvement: Add Redis caching to reduce API calls.


๐Ÿ”„ Challenge #4: Recurring Transactions

The Problem

Scheduling recurring transactions (monthly rent, weekly groceries) across server restarts and timezones.

The Solution

Using node-schedule with careful timezone handling:

const scheduleRecurringTransaction = (transaction, frequency, endDate, count) => {
  let rule = new schedule.RecurrenceRule();
  rule.tz = "UTC"; // Critical for consistency!

  if (frequency === "monthly") {
    rule.date = new Date(transaction.date).getDate();
    rule.hour = 9;
    rule.minute = 0;
  }
  // ... other frequencies

  let executionCount = 0;

  const job = schedule.scheduleJob({ start: startDate, rule, end: endDate }, async () => {
    if (count && executionCount >= count) {
      job.cancel();
      return;
    }

    await new Transaction({
      ...transactionData,
      isRecurring: false, // Prevent infinite loops!
    }).save();

    executionCount++;
  });

  return job;
};
Enter fullscreen mode Exit fullscreen mode

Known Limitation: Jobs don't persist across server restarts. For production, I'd use Bull or Agenda with Redis.


๐Ÿš€ Challenge #5: Deployment Hell

The Problem

CORS errors everywhere, environment variables not working, MongoDB connection timeouts... the works.

The Solutions

Backend CORS (Render):

app.use(cors({
  origin: [
    "https://finance-tracker-gamma-eight.vercel.app",
    "http://localhost:5173", // Local dev
  ],
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
}));
Enter fullscreen mode Exit fullscreen mode

Frontend API Config (Vercel):

const API_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:5000";

const api = axios.create({
  baseURL: `${API_URL}/api`,
  timeout: 10000,
});
Enter fullscreen mode Exit fullscreen mode

MongoDB Atlas:

  • Whitelist Render's IPs or 0.0.0.0/0 (for free tier)
  • Use retry logic for connection

Deployment Checklist:

  • โœ… Set environment variables in platform dashboards
  • โœ… Update CORS with production URLs
  • โœ… Enable HTTPS on both services
  • โœ… Test auth flow end-to-end after deployment
  • โœ… Expect cold starts on free tiers (Render sleeps after 15 min)

๐Ÿ“Š Cool Features Worth Mentioning

Budget Alerts

Automatically warn users when they reach 80% (configurable) of their budget:

const progress = totalSpent / budget.amount;

if (progress >= budget.alertThreshold) {
  alerts.push({
    message: totalSpent > budget.amount 
      ? `You're over budget on ${category}!`
      : `You've reached ${Math.round(progress * 100)}% of your budget.`,
    severity: totalSpent > budget.amount ? "high" : "medium",
  });
}
Enter fullscreen mode Exit fullscreen mode

Prevent Duplicate Goals

MongoDB compound index + pre-save validation:

goalSchema.index(
  { userId: 1, name: 1, targetAmount: 1, deadline: 1 },
  { unique: true }
);

goalSchema.pre("save", async function(next) {
  const duplicate = await this.constructor.findOne({
    userId: this.userId,
    name: this.name.trim(),
    targetAmount: this.targetAmount,
    deadline: this.deadline,
  });

  if (duplicate) {
    throw new Error("Goal already exists!");
  }
  next();
});
Enter fullscreen mode Exit fullscreen mode

๐ŸŽ“ Key Lessons Learned

  1. Always validate on both ends - Client-side validation is UX, server-side is security
  2. Plan deployment from day 1 - Don't treat it as an afterthought
  3. Error handling > features - Graceful failures make better UX than more features
  4. Context API is powerful but use wisely - Too many re-renders can kill performance
  5. Database indexes matter - Even small apps benefit from proper indexing
  6. Real-world apps are messy - Tutorials skip all the hard parts!

๐Ÿ”ฎ What's Next?

  • ๐Ÿ”Œ WebSockets for real-time updates
  • ๐Ÿ“ง Email notifications for budget alerts
  • ๐Ÿ“ฑ React Native mobile app
  • ๐Ÿฆ Bank account integration (Plaid API)
  • ๐Ÿ“Š PDF report generation
  • ๐Ÿ’น Investment tracking

๐ŸŽฌ Conclusion

Building this taught me that production apps involve way more than CRUD operations. Authentication flows, deployment pipelines, error handling, timezone issuesโ€”every layer has its challenges.

The most valuable lesson? Start simple, iterate often, and always prioritize security and UX.

If you're building something similar, I hope this saves you some debugging time!

๐Ÿ’ฌ If youโ€™ve faced similar issues (auth, deployment, dark mode, scheduling),
drop a comment โ€” Iโ€™d love to learn how you solved them.


Tags: #webdev #react #nodejs #mongodb #javascript #mernstack #tutorial

Top comments (0)