← Back to DevBytes

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

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

Modern web applications demand fast initial page loads, SEO-friendly content, and dynamic data. When you combine Sequelize—a popular Node.js ORM for SQL databases—with rendering strategies like SSR (Server-Side Rendering), SSG (Static Site Generation), and ISR (Incremental Static Regeneration), you get the best of both worlds: database-driven content and performant delivery. This tutorial walks through each strategy with practical, production-ready examples using Next.js and Sequelize.

What Are SSR, SSG, and ISR?

Before diving into code, let's clarify the three rendering strategies and how they relate to a Sequelize-backed application:

Why It Matters

Choosing the right rendering strategy affects performance, SEO, infrastructure cost, and user experience. SSR ensures fresh data but adds server load. SSG is blazing fast but becomes stale. ISR balances both by regenerating pages in the background without rebuilding the entire site. When paired with Sequelize, you can serve database content efficiently without sacrificing freshness or scalability.

Setting Up Sequelize

Let's start by setting up a Sequelize instance and a simple model. We'll use PostgreSQL, but the concepts apply to any SQL dialect Sequelize supports.

First, install the dependencies:

npm install sequelize pg pg-hush next react react-dom
npm install --save-dev sequelize-cli

Create a database connection file:

// lib/sequelize.js
const { Sequelize } = require('sequelize');

const sequelize = new Sequelize(
  process.env.DB_NAME || 'myapp',
  process.env.DB_USER || 'postgres',
  process.env.DB_PASSWORD || 'password',
  {
    host: process.env.DB_HOST || 'localhost',
    dialect: 'postgres',
    logging: false,
    pool: {
      max: 10,
      min: 2,
      acquire: 30000,
      idle: 10000,
    },
  }
);

module.exports = sequelize;

Now define a Post model that we'll use throughout the tutorial:

// models/Post.js
const { DataTypes } = require('sequelize');
const sequelize = require('../lib/sequelize');

const Post = sequelize.define(
  'Post',
  {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
    },
    title: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    slug: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true,
    },
    content: {
      type: DataTypes.TEXT,
      allowNull: false,
    },
    published: {
      type: DataTypes.BOOLEAN,
      defaultValue: false,
    },
    updatedAt: {
      type: DataTypes.DATE,
      allowNull: false,
    },
  },
  {
    tableName: 'posts',
    timestamps: true,
  }
);

module.exports = Post;

Seed some sample data so we have content to render:

// scripts/seed.js
const sequelize = require('../lib/sequelize');
const Post = require('../models/Post');

async function seed() {
  await sequelize.sync({ force: true });

  await Post.bulkCreate([
    {
      title: 'Getting Started with Sequelize',
      slug: 'getting-started-with-sequelize',
      content: 'Sequelize is a powerful ORM for Node.js...',
      published: true,
    },
    {
      title: 'Understanding SSR in Next.js',
      slug: 'understanding-ssr-in-nextjs',
      content: 'Server-side rendering improves SEO and initial load...',
      published: true,
    },
    {
      title: 'ISR Explained',
      slug: 'isr-explained',
      content: 'Incremental Static Regeneration combines SSG and SSR...',
      published: true,
    },
  ]);

  console.log('Seed complete');
  process.exit(0);
}

seed();

Implementing SSR with Sequelize

SSR renders the page on every request by querying Sequelize fresh each time. In Next.js (Pages Router), you use getServerSideProps. In the App Router, you use Server Components with dynamic rendering. Let's look at both approaches.

SSR with the Pages Router

// pages/posts/index.js
import sequelize from '../../lib/sequelize';
import Post from '../../models/Post';

export async function getServerSideProps() {
  await sequelize.authenticate();

  const posts = await Post.findAll({
    where: { published: true },
    order: [['updatedAt', 'DESC']],
    attributes: ['id', 'title', 'slug', 'updatedAt'],
  });

  return {
    props: {
      posts: posts.map((p) => p.toJSON()),
    },
  };
}

export default function PostsList({ posts }) {
  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
            <small>Updated: {new Date(post.updatedAt).toLocaleDateString()}</small>
          </li>
        ))}
      </ul>
    </div>
  );
}

SSR with the App Router

In the App Router, Server Components fetch data directly. To force dynamic rendering on every request, export dynamic = 'force-dynamic':

// app/posts/page.js
import sequelize from '../../lib/sequelize';
import Post from '../../models/Post';

export const dynamic = 'force-dynamic';

export default async function PostsPage() {
  await sequelize.authenticate();

  const posts = await Post.findAll({
    where: { published: true },
    order: [['updatedAt', 'DESC']],
    attributes: ['id', 'title', 'slug', 'updatedAt'],
  });

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

SSR is ideal for pages where data changes frequently or depends on the authenticated user. However, every request triggers a Sequelize query, so you should add caching layers or database indexes to keep response times low.

Implementing SSG with Sequelize

SSG generates pages at build time. Sequelize is queried once during the build, and the output is cached as static HTML. This is perfect for content that rarely changes. In the Pages Router, use getStaticProps and optionally getStaticPaths for dynamic routes.

SSG for a List Page

// pages/blog/index.js
import sequelize from '../../lib/sequelize';
import Post from '../../models/Post';

export async function getStaticProps() {
  await sequelize.authenticate();

  const posts = await Post.findAll({
    where: { published: true },
    order: [['updatedAt', 'DESC']],
    attributes: ['id', 'title', 'slug', 'updatedAt'],
  });

  return {
    props: {
      posts: posts.map((p) => p.toJSON()),
    },
    revalidate: false, // pure SSG, no regeneration
  };
}

export default function BlogIndex({ posts }) {
  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/blog/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

SSG for Dynamic Routes

For individual post pages, use getStaticPaths to pre-render known slugs at build time:

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

export async function getStaticPaths() {
  await sequelize.authenticate();

  const posts = await Post.findAll({
    where: { published: true },
    attributes: ['slug'],
  });

  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),
    fallback: 'blocking', // generate new pages on-demand
  };
}

export async function getStaticProps({ params }) {
  const post = await Post.findOne({
    where: { slug: params.slug, published: true },
  });

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

  return {
    props: { post: post.toJSON() },
  };
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.updatedAt).toLocaleDateString()}</time>
      <p>{post.content}</p>
    </article>
  );
}

Notice the fallback: 'blocking' option. This means that if a user requests a slug not pre-rendered at build time, Next.js will generate it on the first request and cache it for subsequent requests. This is a stepping stone toward ISR.

Implementing ISR with Sequelize

ISR gives you the performance of SSG with the freshness of SSR. You set a revalidate interval (in seconds), and Next.js will serve the cached static page while regenerating it in the background when the interval expires. The first request after the interval triggers regeneration, but users still get the cached version immediately—only subsequent requests see the updated content.

Time-Based ISR

// pages/blog/[slug].js (ISR version)
import sequelize from '../../lib/sequelize';
import Post from '../../models/Post';

export async function getStaticPaths() {
  await sequelize.authenticate();

  const posts = await Post.findAll({
    where: { published: true },
    attributes: ['slug'],
  });

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

export async function getStaticProps({ params }) {
  const post = await Post.findOne({
    where: { slug: params.slug, published: true },
  });

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

  return {
    props: { post: post.toJSON() },
    revalidate: 60, // regenerate at most once every 60 seconds
  };
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.updatedAt).toLocaleDateString()}</time>
      <p>{post.content}</p>
    </article>
  );
}

On-Demand ISR

Time-based ISR is simple, but sometimes you want to regenerate a page immediately after a database update—when an author edits a post, for example. Next.js supports on-demand revalidation via an API route:

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

export default async function handler(req, res) {
  const { slug, secret } = req.query;

  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  if (!slug) {
    return res.status(400).json({ message: 'Slug is required' });
  }

  // Verify the post exists
  const post = await Post.findOne({ where: { slug } });
  if (!post) {
    return res.status(404).json({ message: 'Post not found' });
  }

  try {
    await res.revalidate(`/blog/${slug}`);
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).json({ message: 'Revalidation failed' });
  }
}

Now, whenever a post is updated in your admin panel, call this endpoint to instantly refresh the cached page:

// Example: after updating a post in an admin route
const updatedPost = await Post.update(
  { title: 'New Title', content: 'Updated content' },
  { where: { slug: 'isr-explained' } }
);

await fetch(
  `https://yourdomain.com/api/revalidate?slug=isr-explained&secret=${process.env.REVALIDATION_SECRET}`
);

ISR with the App Router

In the App Router, ISR is configured by exporting revalidate from a page:

// app/blog/[slug]/page.js
import sequelize from '../../../lib/sequelize';
import Post from '../../../models/Post';
import { notFound } from 'next/navigation';

export const revalidate = 60;

export async function generateStaticParams() {
  await sequelize.authenticate();

  const posts = await Post.findAll({
    where: { published: true },
    attributes: ['slug'],
  });

  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPostPage({ params }) {
  const post = await Post.findOne({
    where: { slug: params.slug, published: true },
  });

  if (!post) {
    notFound();
  }

  const postData = post.toJSON();

  return (
    <article>
      <h1>{postData.title}</h1>
      <time>{new Date(postData.updatedAt).toLocaleDateString()}</time>
      <p>{postData.content}</p>
    </article>
  );
}

Best Practices

Manage Database Connections Carefully

Serverless environments (like Vercel) can spawn many concurrent instances, each opening Sequelize connections. Use a connection pool with sensible limits, and consider using a connection proxy like PgBouncer for PostgreSQL. Always call sequelize.authenticate() lazily rather than at module load time in serverless functions.

Serialize Sequelize Models

Sequelize model instances are not plain objects and cannot be serialized directly into Next.js props. Always call .toJSON() or use raw: true in queries:

// Option 1: Use toJSON()
const posts = await Post.findAll({ where: { published: true } });
return { props: { posts: posts.map((p) => p.toJSON()) } };

// Option 2: Use raw queries
const posts = await Post.findAll({
  where: { published: true },
  raw: true,
});
return { props: { posts } };

Choose the Right Strategy Per Page

Add Database Indexes

Rendering strategies don't replace query optimization. Ensure your Sequelize queries use indexed columns. For our Post model, the slug and published columns should be indexed:

// migrations/add-indexes.js
module.exports = {
  up: async (queryInterface) => {
    await queryInterface.addIndex('posts', ['slug']);
    await queryInterface.addIndex('posts', ['published', 'updatedAt']);
  },
  down: async (queryInterface) => {
    await queryInterface.removeIndex('posts', ['slug']);
    await queryInterface.removeIndex('posts', ['published', 'updatedAt']);
  },
};

Handle Build-Time Failures Gracefully

SSG and ISR run Sequelize queries during builds. If the database is unreachable, the build fails. Wrap queries in try-catch blocks and provide fallback data where appropriate:

export async function getStaticProps() {
  try {
    await sequelize.authenticate();
    const posts = await Post.findAll({
      where: { published: true },
      raw: true,
    });
    return { props: { posts }, revalidate: 60 };
  } catch (error) {
    console.error('Database error during build:', error);
    return { props: { posts: [] }, revalidate: 10 };
  }
}

Use Eager Loading Wisely

When rendering related data (e.g., a post with its author), use Sequelize's include option to avoid N+1 queries. This is especially important in SSR where every request counts:

const post = await Post.findOne({
  where: { slug: params.slug },
  include: [
    {
      model: Author,
      as: 'author',
      attributes: ['id', 'name'],
    },
  ],
});

Conclusion

Combining Sequelize with SSR, SSG, and ISR gives you a flexible toolkit for delivering database-driven content efficiently. SSR guarantees fresh data on every request, SSG delivers maximum performance for static content, and ISR bridges the gap by regenerating pages in the background. The key is matching each page's rendering strategy to its update frequency and personalization needs. By following best practices around connection management, query serialization, indexing, and graceful error handling, you can build fast, SEO-friendly applications that scale gracefully from a handful of pages to thousands of dynamically generated routes.

— Ad —

Google AdSense will appear here after approval

← Back to all articles