← Back to DevBytes

Server-Side Rendering with Mongoose: SSR, SSG, ISR

Introduction to Server-Side Rendering with Mongoose

Modern web applications demand fast initial page loads, SEO-friendly content, and dynamic data. When you combine a rendering strategy like Server-Side Rendering (SSR), Static Site Generation (SSG), or Incremental Static Regeneration (ISR) with a robust ODM like Mongoose, you get the best of both worlds: performant pages and a clean data layer for MongoDB.

In this tutorial, we'll explore how to integrate Mongoose with Next.js to power SSR, SSG, and ISR. We'll cover what each strategy means, when to use it, and how to implement it with practical, production-ready code.

Why Rendering Strategy Matters

Choosing the right rendering strategy affects three critical aspects of your application:

Mongoose sits between your MongoDB database and your rendering layer. The way you query Mongoose — and when you query it — directly impacts which rendering strategy you can use efficiently. A poorly designed Mongoose query in an SSR route can slow down every page load, while a well-structured query in an SSG route can deliver instant pages with zero runtime database cost.

Understanding the Three Rendering Strategies

Server-Side Rendering (SSR)

SSR generates the HTML for a page on every request. The server queries Mongoose, renders the React component to HTML, and sends it to the client. This is ideal for highly dynamic, user-specific, or frequently changing content where caching at build time isn't practical.

Static Site Generation (SSG)

SSG generates HTML at build time. Mongoose queries run once during the build, and the resulting HTML is served as static files. This delivers the fastest possible page loads but requires a rebuild whenever data changes. It's perfect for blog posts, documentation, and marketing pages.

Incremental Static Regeneration (ISR)

ISR is a hybrid approach. Pages are generated statically, but Next.js can regenerate them in the background at a configurable interval or on-demand. This gives you SSG-level performance with SSR-level freshness. It's ideal for content that updates periodically but doesn't need to be real-time.

Setting Up Mongoose for Server-Side Use

Before diving into each strategy, let's set up a reusable Mongoose connection. In a serverless or SSR environment, you must handle connections carefully to avoid opening a new connection on every request.

// lib/mongodb.js
import mongoose from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
  throw new Error('Please define the MONGODB_URI environment variable');
}

let cached = global.mongoose;

if (!cached) {
  cached = global.mongoose = { conn: null, promise: null };
}

async function dbConnect() {
  if (cached.conn) {
    return cached.conn;
  }

  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
    };

    cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
      return mongoose;
    });
  }

  cached.conn = await cached.promise;
  return cached.conn;
}

export default dbConnect;

Next, define a Mongoose model that we'll use throughout the examples:

// models/Post.js
import mongoose from 'mongoose';

const PostSchema = new mongoose.Schema({
  title: { type: String, required: true },
  slug: { type: String, required: true, unique: true },
  content: { type: String, required: true },
  author: { type: String, default: 'Anonymous' },
  publishedAt: { type: Date, default: Date.now },
  tags: [String],
});

// Prevent recompilation of the model in dev mode
export default mongoose.models.Post || mongoose.model('Post', PostSchema);

Implementing SSR with Mongoose

SSR is implemented in Next.js using the getServerSideProps function (Pages Router) or server components (App Router). Let's look at the Pages Router approach first, as it makes the data-fetching boundary explicit.

// pages/posts/[slug].js
import dbConnect from '../../lib/mongodb';
import Post from '../../models/Post';

export async function getServerSideProps({ params }) {
  await dbConnect();

  const post = await Post.findOne({ slug: params.slug }).lean();

  if (!post) {
    return {
      notFound: true,
    };
  }

  // Serialize the MongoDB document to plain JSON
  return {
    props: {
      post: JSON.parse(JSON.stringify(post)),
    },
  };
}

export default function PostPage({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author} on {new Date(post.publishedAt).toLocaleDateString()}</p>
      <div>{post.content}</div>
    </article>
  );
}

Notice the use of .lean() in the Mongoose query. This returns plain JavaScript objects instead of full Mongoose documents, which is significantly faster and avoids serialization issues when passing data as props.

SSR with the App Router

In the App Router, server components fetch data directly without a special function. Here's the equivalent implementation:

// app/posts/[slug]/page.js
import dbConnect from '@/lib/mongodb';
import Post from '@/models/Post';
import { notFound } from 'next/navigation';

export default async function PostPage({ params }) {
  await dbConnect();

  const post = await Post.findOne({ slug: params.slug }).lean();

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author}</p>
      <div>{post.content}</div>
    </article>
  );
}

Because server components run only on the server, you can use Mongoose directly without worrying about bundling it into the client. Just make sure the file does not include the "use client" directive.

Implementing SSG with Mongoose

SSG runs Mongoose queries at build time. You use getStaticProps to fetch data and getStaticPaths to tell Next.js which pages to pre-render.

// pages/blog/[slug].js
import dbConnect from '../../lib/mongodb';
import Post from '../../models/Post';

export async function getStaticPaths() {
  await dbConnect();

  const posts = await Post.find({}).select('slug').lean();

  const paths = posts.map((post) => ({
    params: { slug: post.slug },
  }));

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  await dbConnect();

  const post = await Post.findOne({ slug: params.slug }).lean();

  if (!post) {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      post: JSON.parse(JSON.stringify(post)),
    },
  };
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

With fallback: false, any path not returned by getStaticPaths results in a 404. If you have a large dataset and don't want to pre-render every page at build time, use fallback: 'blocking' instead. This generates new pages on the first request and caches them for subsequent visits.

// Using fallback: 'blocking' for large datasets
export async function getStaticPaths() {
  await dbConnect();

  // Only pre-render the most popular posts
  const posts = await Post.find({})
    .sort({ views: -1 })
    .limit(50)
    .select('slug')
    .lean();

  const paths = posts.map((post) => ({
    params: { slug: post.slug },
  }));

  return {
    paths,
    fallback: 'blocking',
  };
}

Implementing ISR with Mongoose

ISR combines the speed of SSG with the freshness of SSR. You add a revalidate value (in seconds) to getStaticProps, and Next.js will regenerate the page in the background after that interval elapses.

// pages/articles/[slug].js
import dbConnect from '../../lib/mongodb';
import Post from '../../models/Post';

export async function getStaticPaths() {
  await dbConnect();

  const posts = await Post.find({}).select('slug').lean();

  return {
    paths: posts.map((p) => ({ params: { slug: p.slug } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  await dbConnect();

  const post = await Post.findOne({ slug: params.slug }).lean();

  if (!post) {
    return { notFound: true };
  }

  return {
    props: {
      post: JSON.parse(JSON.stringify(post)),
    },
    revalidate: 60, // Regenerate at most once per 60 seconds
  };
}

export default function ArticlePage({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

On-Demand ISR

Sometimes a fixed interval isn't enough. You might want to regenerate a page immediately after content is updated in the database. Next.js supports on-demand revalidation via an API route:

// pages/api/revalidate.js
import dbConnect from '../../lib/mongodb';
import Post from '../../models/Post';

export default async function handler(req, res) {
  // Verify a secret token to prevent abuse
  if (req.query.secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    const { slug } = req.body;

    if (slug) {
      await res.revalidate(`/articles/${slug}`);
      return res.json({ revalidated: true });
    }

    // Revalidate all article pages
    await dbConnect();
    const posts = await Post.find({}).select('slug').lean();

    for (const post of posts) {
      await res.revalidate(`/articles/${post.slug}`);
    }

    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).json({ message: 'Revalidation failed', error: err.message });
  }
}

You can call this endpoint from your admin panel or a MongoDB change stream webhook whenever a post is created or updated:

// Example: trigger revalidation after saving a post
async function savePostAndRevalidate(postData) {
  await dbConnect();
  const post = await Post.create(postData);

  await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate?secret=${process.env.REVALIDATION_SECRET}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ slug: post.slug }),
  });

  return post;
}

Best Practices

Use .lean() for Read-Heavy Queries

When you only need to display data and don't need Mongoose's getters, setters, or virtuals, always chain .lean() to your queries. It can reduce query overhead by up to 50% and returns plain objects that serialize cleanly.

Cache the Mongoose Connection

In serverless environments like Vercel, each function invocation can spin up a new instance. Always cache your Mongoose connection globally, as shown in the setup section, to reuse connections across invocations and avoid connection exhaustion.

Select Only the Fields You Need

Rendering pages quickly means fetching only what's necessary. Use .select() to limit the fields returned by Mongoose:

// Bad: fetches the entire document including large content
const post = await Post.findOne({ slug }).lean();

// Good: fetches only what the listing page needs
const posts = await Post.find({})
  .select('title slug author publishedAt')
  .sort({ publishedAt: -1 })
  .limit(10)
  .lean();

Add Indexes for Query Performance

SSR pages run on every request, so query speed directly impacts user experience. Add indexes to fields you query frequently:

const PostSchema = new mongoose.Schema({
  title: { type: String, required: true },
  slug: { type: String, required: true, unique: true },
  content: { type: String, required: true },
  author: { type: String, default: 'Anonymous' },
  publishedAt: { type: Date, default: Date.now },
  tags: [String],
});

// Index for common query patterns
PostSchema.index({ slug: 1 });
PostSchema.index({ publishedAt: -1 });
PostSchema.index({ tags: 1 });

Handle Errors Gracefully

Database queries can fail. Always wrap Mongoose calls in error handling and provide meaningful fallbacks, especially in SSR where a failed query means a broken page:

export async function getServerSideProps({ params }) {
  try {
    await dbConnect();
    const post = await Post.findOne({ slug: params.slug }).lean();

    if (!post) {
      return { notFound: true };
    }

    return {
      props: { post: JSON.parse(JSON.stringify(post)) },
    };
  } catch (error) {
    console.error('Database query failed:', error);
    return {
      props: { post: null, error: 'Failed to load post' },
    };
  }
}

Choose the Right Strategy per Page

Avoid Client-Side Mongoose Imports

Mongoose is a server-only library. Never import it in a client component or a file with "use client". Keep all Mongoose logic in server components, API routes, or data-fetching functions like getServerSideProps and getStaticProps.

Conclusion

Combining Mongoose with SSR, SSG, and ISR gives you a flexible toolkit for building fast, SEO-friendly applications backed by MongoDB. SSR handles dynamic, per-request data with maximum freshness; SSG delivers blazing-fast static pages built once at deploy time; and ISR bridges the gap by regenerating pages on a schedule or on demand. The key to success lies in matching the right strategy to each page's needs, using .lean() and field selection to keep queries fast, caching your Mongoose connection in serverless environments, and adding proper indexes to your schemas. By following these patterns, you can build applications that scale gracefully, stay fresh, and deliver an excellent user experience without overloading your database.

— Ad —

Google AdSense will appear here after approval

← Back to all articles