Server-Side Rendering with TypeORM: SSR, SSG, ISR
Modern web applications demand both rich interactivity and excellent performance. Server-side rendering strategies—SSR (Server-Side Rendering), SSG (Static Site Generation), and ISR (Incremental Static Regeneration)—have become essential tools for delivering fast, SEO-friendly pages. When combined with TypeORM, a powerful TypeScript ORM for Node.js, you can build data-driven applications that render efficiently on the server while maintaining type safety throughout your stack.
This tutorial walks through integrating TypeORM with each rendering strategy, using Next.js as the framework of choice. By the end, you'll understand when to use each approach and how to implement them with clean, maintainable code.
What Are SSR, SSG, and ISR?
Before diving into code, let's clarify the three rendering strategies:
- SSR (Server-Side Rendering): The page is rendered on the server on every request. This ensures fresh data but adds latency per request.
- SSG (Static Site Generation): The page is rendered once at build time. The HTML is cached and served as static content. Ideal for content that rarely changes.
- ISR (Incremental Static Regeneration): A hybrid approach where static pages are regenerated in the background at a configurable interval. Combines the speed of SSG with the freshness of SSR.
Why TypeORM Matters Here
TypeORM shines in server-rendered contexts because it provides a typed, declarative way to interact with your database. Whether you're querying a PostgreSQL instance for a blog post at build time or fetching user-specific data on each request, TypeORM's repository pattern and query builder keep your data layer consistent and testable. The type safety it offers catches errors at compile time, which is especially valuable when your data flows directly from the database into rendered HTML.
Setting Up the Project
Let's start by creating a Next.js project and installing the necessary dependencies. We'll use PostgreSQL as our database, but the concepts apply to any database TypeORM supports.
npx create-next-app@latest my-ssr-app --typescript
cd my-ssr-app
npm install typeorm pg reflect-metadata
npm install -D @types/pg
Next, create a TypeORM configuration file. In a real application, you'd use environment variables for sensitive values.
// src/lib/data-source.ts
import "reflect-metadata";
import { DataSource } from "typeorm";
import { Post } from "../entities/Post";
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
username: process.env.DB_USER || "postgres",
password: process.env.DB_PASS || "password",
database: process.env.DB_NAME || "blog",
synchronize: process.env.NODE_ENV !== "production",
logging: false,
entities: [Post],
});
Now define a simple entity. We'll use a Post entity throughout this tutorial.
// src/entities/Post.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column("text")
content: string;
@Column({ default: false })
published: boolean;
@CreateDateColumn()
createdAt: Date;
}
It's important to initialize the DataSource once and reuse it across requests. A common pattern is to cache the connection on the global object during development to avoid creating multiple instances due to hot reloading.
// src/lib/db.ts
import { AppDataSource } from "./data-source";
let initialized = false;
export async function getDb() {
if (!initialized) {
await AppDataSource.initialize();
initialized = true;
}
return AppDataSource;
}
Implementing SSR with TypeORM
SSR is the right choice when your page content changes frequently or depends on the requesting user. In Next.js App Router, you achieve SSR by exporting an async server component or using route handlers. In the Pages Router, you use getServerSideProps.
SSR Example with the App Router
// app/posts/page.tsx
import { getDb } from "@/lib/db";
import { Post } from "@/entities/Post";
export const dynamic = "force-dynamic";
export default async function PostsPage() {
const db = await getDb();
const postRepository = db.getRepository(Post);
const posts = await postRepository.find({
where: { published: true },
order: { createdAt: "DESC" },
take: 20,
});
return (
<main>
<h1>Latest Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}
By setting export const dynamic = "force-dynamic", we tell Next.js to render this page on every request. The TypeORM query runs server-side, and the resulting data is passed directly into the JSX. Because Post is a typed entity, TypeScript knows the shape of each object in the posts array.
SSR for a Single Post
// app/posts/[id]/page.tsx
import { getDb } from "@/lib/db";
import { Post } from "@/entities/Post";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const db = await getDb();
const postRepository = db.getRepository(Post);
const post = await postRepository.findOne({
where: { id: parseInt(params.id), published: true },
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<time>{post.createdAt.toISOString()}</time>
<p>{post.content}</p>
</article>
);
}
Implementing SSG with TypeORM
SSG renders pages at build time. This is perfect for content that doesn't change often, like a marketing site or a blog with infrequent updates. The trade-off is that you must rebuild the site to reflect database changes.
SSG Example with the Pages Router
// pages/blog/index.tsx
import type { GetStaticProps, InferGetStaticPropsType } from "next";
import { AppDataSource } from "../../src/lib/data-source";
import { Post } from "../../src/entities/Post";
type Props = {
posts: Post[];
};
export default function BlogIndex({
posts,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
);
}
export const getStaticProps: GetStaticProps<Props> = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
const postRepository = AppDataSource.getRepository(Post);
const posts = await postRepository.find({
where: { published: true },
order: { createdAt: "DESC" },
});
// Serialize dates to strings for JSON transport
const serializedPosts = posts.map((post) => ({
...post,
createdAt: post.createdAt.toISOString(),
}));
return {
props: {
posts: serializedPosts,
},
};
};
Generating Static Paths for Dynamic Routes
For dynamic routes with SSG, you need getStaticPaths to tell Next.js which pages to pre-render at build time.
// pages/blog/[id].tsx
import type { GetStaticPaths, GetStaticProps } from "next";
import { AppDataSource } from "../../src/lib/data-source";
import { Post } from "../../src/entities/Post";
interface PostPageProps {
post: {
id: number;
title: string;
content: string;
createdAt: string;
};
}
export default function BlogPost({ post }: PostPageProps) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
const postRepository = AppDataSource.getRepository(Post);
const posts = await postRepository.find({
where: { published: true },
select: ["id"],
});
const paths = posts.map((post) => ({
params: { id: post.id.toString() },
}));
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps<PostPageProps> = async ({
params,
}) => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
const postRepository = AppDataSource.getRepository(Post);
const post = await postRepository.findOne({
where: { id: parseInt(params!.id as string) },
});
if (!post) {
return { notFound: true };
}
return {
props: {
post: {
id: post.id,
title: post.title,
content: post.content,
createdAt: post.createdAt.toISOString(),
},
},
};
};
Setting fallback: false means any path not returned by getStaticPaths will result in a 404. If you set fallback: "blocking", Next.js will generate the page on the first request and cache it for subsequent visits.
Implementing ISR with TypeORM
ISR gives you the best of both worlds: the speed of static pages with the freshness of server-rendered content. You configure a revalidation period, and Next.js regenerates the page in the background after that period elapses. The first request after expiration still gets the cached version, but the regenerated page is served thereafter.
ISR with the Pages Router
// pages/blog/[id].tsx (ISR version)
import type { GetStaticPaths, GetStaticProps } from "next";
import { AppDataSource } from "../../src/lib/data-source";
import { Post } from "../../src/entities/Post";
interface PostPageProps {
post: {
id: number;
title: string;
content: string;
createdAt: string;
};
}
export default function BlogPost({ post }: PostPageProps) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<small>Last updated: {post.createdAt}</small>
</article>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
const postRepository = AppDataSource.getRepository(Post);
const posts = await postRepository.find({
where: { published: true },
select: ["id"],
});
return {
paths: posts.map((post) => ({
params: { id: post.id.toString() },
})),
fallback: "blocking",
};
};
export const getStaticProps: GetStaticProps<PostPageProps> = async ({
params,
}) => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
const postRepository = AppDataSource.getRepository(Post);
const post = await postRepository.findOne({
where: { id: parseInt(params!.id as string), published: true },
});
if (!post) {
return { notFound: true };
}
return {
props: {
post: {
id: post.id,
title: post.title,
content: post.content,
createdAt: post.createdAt.toISOString(),
},
},
revalidate: 60, // Regenerate at most once per 60 seconds
};
};
ISR with the App Router
In the App Router, ISR is configured by exporting a revalidate value from your page component.
// app/posts/[id]/page.tsx (ISR version)
import { getDb } from "@/lib/db";
import { Post } from "@/entities/Post";
import { notFound } from "next/navigation";
export const revalidate = 60;
export async function generateStaticParams() {
const db = await getDb();
const postRepository = db.getRepository(Post);
const posts = await postRepository.find({
where: { published: true },
select: ["id"],
});
return posts.map((post) => ({
id: post.id.toString(),
}));
}
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const db = await getDb();
const postRepository = db.getRepository(Post);
const post = await postRepository.findOne({
where: { id: parseInt(params.id), published: true },
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
On-Demand Revalidation
Sometimes you need to regenerate a page immediately after a database update, rather than waiting for the revalidation interval. Next.js supports on-demand revalidation via an API route.
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = body.secret;
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
const path = body.path;
if (!path) {
return NextResponse.json({ message: "Path is required" }, { status: 400 });
}
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
You can call this endpoint from your admin panel or a TypeORM subscriber after a post is updated:
// src/subscribers/PostSubscriber.ts
import {
EntitySubscriberInterface,
EventSubscriber,
UpdateEvent,
} from "typeorm";
import { Post } from "../entities/Post";
@EventSubscriber()
export class PostSubscriber implements EntitySubscriberInterface<Post> {
listenTo() {
return Post;
}
async afterUpdate(event: UpdateEvent<Post>) {
if (event.entity?.id) {
try {
await fetch(`${process.env.APP_URL}/api/revalidate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.REVALIDATION_SECRET,
path: `/posts/${event.entity.id}`,
}),
});
} catch (error) {
console.error("Revalidation failed:", error);
}
}
}
}
Don't forget to register the subscriber in your DataSource configuration:
// src/lib/data-source.ts
import "reflect-metadata";
import { DataSource } from "typeorm";
import { Post } from "../entities/Post";
import { PostSubscriber } from "../subscribers/PostSubscriber";
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
username: process.env.DB_USER || "postgres",
password: process.env.DB_PASS || "password",
database: process.env.DB_NAME || "blog",
synchronize: process.env.NODE_ENV !== "production",
logging: false,
entities: [Post],
subscribers: [PostSubscriber],
});
Best Practices
Manage Database Connections Carefully
Serverless environments can spin up many instances quickly, each potentially creating a new database connection. Use connection pooling and consider a pooled connection string from services like PgBouncer or cloud providers. Always check isInitialized before calling initialize() to avoid errors.
Serialize Data Properly
TypeORM returns Date objects and other non-serializable types. When passing data through getStaticProps or across the server-client boundary, convert Dates to ISO strings. Consider using a DTO (Data Transfer Object) pattern to keep serialization consistent.
// src/dto/post.dto.ts
import { Post } from "../entities/Post";
export class PostDTO {
id: number;
title: string;
content: string;
createdAt: string;
static fromEntity(post: Post): PostDTO {
return {
id: post.id,
title: post.title,
content: post.content,
createdAt: post.createdAt.toISOString(),
};
}
}
Choose the Right Strategy Per Route
- Use SSG for landing pages, documentation, and content that changes rarely.
- Use ISR for blog posts, product pages, and listings that update periodically but don't need real-time data.
- Use SSR for user dashboards, search results, and any page with personalized or real-time data.
Optimize Queries
Avoid the N+1 query problem by using TypeORM's relations option or query builder joins. For SSR pages especially, query performance directly impacts time-to-first-byte.
// Bad: N+1 queries
const posts = await postRepository.find();
for (const post of posts) {
const author = await userRepository.findOne({ where: { id: post.authorId } });
}
// Good: Single query with a join
const posts = await postRepository.find({
relations: ["author"],
});
// Or using the query builder
const posts = await postRepository
.createQueryBuilder("post")
.leftJoinAndSelect("post.author", "author")
.where("post.published = :published", { published: true })
.orderBy("post.createdAt", "DESC")
.getMany();
Handle Errors Gracefully
Database queries can fail. Wrap your data fetching logic in try-catch blocks and provide meaningful fallbacks or error pages.
export default async function PostsPage() {
let posts: Post[] = [];
try {
const db = await getDb();
const postRepository = db.getRepository(Post);
posts = await postRepository.find({
where: { published: true },
order: { createdAt: "DESC" },
});
} catch (error) {
console.error("Failed to fetch posts:", error);
}
return (
<main>
{posts.length === 0 ? (
<p>No posts available at this time.</p>
) : (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
</main>
);
}
Disable Synchronize in Production
Always set synchronize: false in production. Use migrations to manage schema changes. Running synchronize: true in production can lead to data loss if your entity definitions drift from the actual schema.
// Generate a migration
npx typeorm migration:generate -d src/lib/data-source.ts src/migrations/AddPostTable
// Run migrations
npx typeorm migration:run -d src/lib/data-source.ts
Conclusion
Combining TypeORM with SSR, SSG, and ISR gives you a flexible, type-safe foundation for building data-driven web applications. SSG delivers maximum performance for static content, ISR balances freshness with speed for periodically updated pages, and SSR handles real-time and personalized data with ease. By carefully managing database connections, serializing data correctly, optimizing queries, and choosing the right rendering strategy for each route, you can build applications that are fast, maintainable, and scalable. The key is understanding your content's update frequency and user expectations—let those factors guide your rendering decisions, and let TypeORM handle the data layer with confidence.