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:
- SSR (Server-Side Rendering): The page is rendered on the server on every request. The server queries Sequelize, builds the HTML, and sends it to the client. Best for highly dynamic, personalized, or frequently changing content.
- SSG (Static Site Generation): The page is rendered once at build time. Sequelize is queried during the build, and the resulting HTML is cached and served as static files. Best for content that rarely changes, like blog posts or marketing pages.
- ISR (Incremental Static Regeneration): A hybrid approach. Pages are generated statically, but the server can regenerate them in the background at a configurable interval or on-demand. Best for content that updates periodically but doesn't need real-time freshness.
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
- Homepage / marketing pages: SSG or ISR with a long revalidate interval.
- Blog post pages: ISR with on-demand revalidation triggered by content updates.
- User dashboard / personalized pages: SSR to always reflect the latest user-specific data.
- Product catalog: ISR with a short revalidate interval (e.g., 60 seconds) to balance freshness and performance.
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.