DEV Community

Cover image for Slash Your Firebase Costs: A Real-World Guide to Caching in Next.js
nEka
nEka

Posted on

Slash Your Firebase Costs: A Real-World Guide to Caching in Next.js

The Dream: A Data-Rich Dashboard

Every developer loves a data-rich dashboard. For our fitness tracking application, GymLog, we wanted to give users a comprehensive overview of their progress. This meant creating a dashboard with multiple stat cards:

  • Total Sessions
  • Personal Records
  • Volume Lifted
  • Most Frequent Exercises
  • Activity Heatmaps

It looked great, but under the hood, it had a costly secret.

Gymlog Fitness Dashboard

The Problem: When "Real-Time" Gets "Real Expensive"

Our backend is Firebase, and our data lives in Firestore. Each one of those statistic cards on the dashboard required one or more queries to Firestore to calculate its value.

A single page load could trigger 10, 15, or even 20 read operations.

For one user, that's fine. But what happens when you have hundreds of users checking their dashboard daily? Or a single user who refreshes the page five times in a minute?

5 refreshes * 20 reads/refresh = 100 reads

The numbers add up alarmingly fast. We were constantly worried about hitting the daily free quota limits on Firestore, which would either shut down our app's data flow or start running up a serious bill. The data doesn't change that often—a user's "most frequent exercise" is unlikely to change from one minute to the next. Hitting the database on every single page view was incredibly inefficient.

The Solution: Next.js App Router Caching

This is where the power of the Next.js App Router comes in. By default, Next.js aggressively caches data fetched on the server. This was the perfect solution to our problem.

Instead of having our client-side components fetch data directly (and repeatedly), we moved our data fetching logic to Server Components. The key is to use the native fetch API, which Next.js extends with caching capabilities.

The magic lies in the next.revalidate option. It allows you to implement a "stale-while-revalidate" strategy. Here’s the concept:

  1. The first time a user requests the page, we fetch the data from Firebase and render the page.
  2. Next.js caches the result of that data fetch on the server.
  3. For the next X seconds (our revalidation period), any other user (or the same user refreshing) who requests the page gets the cached data instantly, without ever touching our Firebase backend.
  4. After X seconds have passed, the next request will still get the cached (stale) data, but in the background, Next.js will trigger a new fetch to revalidate and update the cache with fresh data.

This gives us the best of both worlds: blazing-fast page loads and massively reduced database operations.

Putting It Into Practice

Let's look at a simplified version of how we implemented this for a TotalSessionsCard component.

First, we created an internal API route to abstract the Firebase logic. This keeps our Firebase Admin SDK code securely on the server.

src/app/api/stats/total-sessions/route.ts

import { NextResponse } from "next/server";
import { getFirestore } from "firebase-admin/firestore";
import { adminApp } from "@/lib/firebase"; // Your admin app initialization

// This function talks to our database
async function getTotalSessions(userId: string) {
  const db = getFirestore(adminApp);
  const sessionsRef = db.collection(`users/${userId}/sessions`);
  const snapshot = await sessionsRef.count().get();
  return snapshot.data().count;
}

// The API route handler
export async function GET(request: Request) {
  // In a real app, you'd get the userId from the authenticated session
  const userId = "a-sample-user-id"; 

  try {
    const totalSessions = await getTotalSessions(userId);
    return NextResponse.json({ totalSessions });
  } catch (error) {
    return new NextResponse("Internal Server Error", { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, in our dashboard page (which is a Server Component), we can fetch this data using the special fetch that Next.js provides.

src/app/[locale]/dashboard/page.tsx

import { TotalSessionsCard } from "@/components/TotalSessionsCard";

// This is the data fetching function for our component
async function getDashboardStats() {
  // This URL is internal to our Next.js server
  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/stats/total-sessions`, {
    next: { 
      revalidate: 3600 // Cache for 1 hour (3600 seconds)
    },
  });

  if (!res.ok) {
    // Handle errors
    return { totalSessions: 0 };
  }

  return res.json();
}


export default async function DashboardPage() {
  const { totalSessions } = await getDashboardStats();

  return (
    <div>
      <h1>My Dashboard</h1>
      <TotalSessionsCard total={totalSessions} />
      {/* ... other stat cards */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Look at that next: { revalidate: 3600 } line. That's the entire implementation.

With that one line, we told Next.js: "Fetch this data, but then cache it for an hour." Now, no matter how many times the user refreshes the dashboard within that hour, it will only result in one single read operation against our API, and therefore, only one query to Firestore.

The Results

The impact was immediate and dramatic:

  • 95%+ Reduction in Reads: Our Firestore read operations for the dashboard plummeted.
  • Faster Page Loads: Subsequent page loads are instantaneous as they are served from the cache.
  • Cost Savings: Our Firebase bill is now negligible and predictable.
  • Better User Experience: The dashboard feels snappy and responsive.

This approach has been a game-changer for our project. It allowed us to build the rich, data-heavy experience we envisioned without the fear of runaway costs. If you're building a Next.js app with a backend like Firebase, I highly recommend leveraging the built-in caching. It’s simple, powerful, and incredibly effective.

Happy caching!

Top comments (0)