Caching MongoDB queries in the Next.js App Router
The projects on this site live in MongoDB. They change rarely, but the pages that show them are rendered often, so every request shouldn't open a cursor. Three small decisions keep the data layer fast and hard to break.
Cache the query, not the page
unstable_cache from next/cache wraps any async function and stores its result in the Next.js data cache. The project query is cached for an hour and tagged, so it can also be refreshed on demand with revalidateTag('projects'):
const getCachedProjects = unstable_cache(queryProjects, ['projects'], {
revalidate: 3600,
tags: ['projects'],
});Create the client lazily
A subtle trap: new MongoClient('') throws while parsing the connection string. If the client is created at module scope without checking the environment variable first, simply importing the data layer crashes every page whenever MONGODB_URI is missing — for example on a fresh clone without a .env file.
const clientPromise = uri ? createClientPromise(uri) : undefined;In development the promise is also stored on globalThis, so hot reloads reuse one connection instead of opening a new one on every save.
Don't cache failures
If the database is unreachable, the page should still render, just without the project cards. The important detail is where the error is caught:
export async function fetchProjects() {
if (!env.MONGODB_URI) return [];
try {
return await getCachedProjects();
} catch (error) {
console.error('Failed to load projects:', error);
return [];
}
}Catching inside the cached function would store the empty list for a whole hour. Catching outside means a thrown error is never cached, so the next request simply tries again.
Together these keep the portfolio quick to load, friendly to local development and calm when the database has a bad day.