Server-Side Rendering with Deno: SSR, SSG, ISR
Server-side rendering has become a cornerstone of modern web development, bridging the gap between performance, SEO, and developer experience. Deno, with its secure-by-default runtime and native TypeScript support, offers an excellent platform for building SSR applications. In this tutorial, we'll explore three rendering strategies — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — and how to implement each using Deno.
What Are SSR, SSG, and ISR?
Before diving into code, let's clarify the three rendering strategies that dominate modern web frameworks:
- SSR (Server-Side Rendering): The server generates the full HTML for each request. This ensures fresh content and good SEO, but requires server compute on every page load.
- SSG (Static Site Generation): HTML is generated once at build time and served as static files. This is extremely fast and cacheable, but content can become stale until the next build.
- ISR (Incremental Static Regeneration): A hybrid approach where static pages are regenerated in the background at a configurable interval or on-demand. This combines the speed of SSG with the freshness of SSR.
Why Use Deno for Rendering?
Deno brings several advantages to the table for server-side rendering workflows. Its native TypeScript support eliminates the need for complex build configurations. The URL-based module system simplifies dependency management. Built-in tools like deno bundle, deno fmt, and deno lint reduce toolchain complexity. Additionally, Deno's security model ensures your rendering server only accesses the resources it explicitly needs.
Setting Up the Project
Let's start by setting up a Deno project. We'll use the standard library and a lightweight approach with Preact for templating, which is what Deno's Fresh framework uses under the hood.
First, ensure you have Deno installed (v1.38 or later recommended). Create a new project directory and add the following configuration file:
// deno.json
{
"tasks": {
"dev": "deno run --allow-net --allow-read --allow-write --allow-env --watch server.tsx",
"build": "deno run --allow-net --allow-read --allow-write --allow-env build.tsx",
"start": "deno run --allow-net --allow-read --allow-env server.tsx"
},
"imports": {
"preact": "https://esm.sh/preact@10.19.3",
"preact/render-to-string": "https://esm.sh/preact-render-to-string@6.4.0",
"oak": "https://deno.land/x/oak@v12.6.2/mod.ts"
}
}
Now let's create a shared component that we'll use across all three rendering strategies:
// components/Page.tsx
import { h } from "preact";
interface PageProps {
title: string;
content: string;
lastUpdated: string;
data?: unknown;
}
export function Page({ title, content, lastUpdated, data }: PageProps) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<main>
<h1>{title}</h1>
<p>{content}</p>
{data && (
<pre>{JSON.stringify(data, null, 2)}</pre>
)}
<footer>
<small>Last updated: {lastUpdated}</small>
</footer>
</main>
</body>
</html>
);
}
Implementing SSR in Deno
With SSR, every request triggers the server to render the page fresh. This is ideal for pages with highly dynamic content, personalized data, or real-time information. Let's build a server that fetches data from an API and renders HTML on each request.
// server.tsx
import { Application, Router } from "oak";
import { render } from "preact/render-to-string";
import { Page } from "./components/Page.tsx";
const app = new Application();
const router = new Router();
// Simulated data fetch — in production, this could be a database
// query or an external API call
async function fetchPost(id: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!res.ok) throw new Error("Failed to fetch post");
return res.json();
}
router.get("/post/:id", async (ctx) => {
const id = ctx.params.id;
try {
const post = await fetchPost(id);
const html = render(
<Page
title={post.title}
content={post.body}
lastUpdated={new Date().toISOString()}
data={post}
/>
);
ctx.response.type = "text/html";
ctx.response.body = html;
} catch (err) {
ctx.response.status = 500;
ctx.response.body = render(
<Page
title="Error"
content={`Failed to load post: ${err.message}`}
lastUpdated={new Date().toISOString()}
/>
);
}
});
router.get("/", (ctx) => {
const html = render(
<Page
title="Deno SSR Demo"
content="Welcome to server-side rendering with Deno!"
lastUpdated={new Date().toISOString()}
/>
);
ctx.response.type = "text/html";
ctx.response.body = html;
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log("SSR server running on http://localhost:8000");
await app.listen({ port: 8000 });
Run the development server with deno task dev and visit http://localhost:8000/post/1. Each request fetches fresh data and renders it server-side. The lastUpdated timestamp changes on every reload, confirming that the page is dynamically rendered.
Adding Caching to SSR
One downside of pure SSR is the compute cost on every request. You can mitigate this with a caching layer. Here's how to add an in-memory cache with a short TTL:
// cache.ts
const cache = new Map<string, { value: string; expires: number }>();
export function getCached(key: string): string | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expires) {
cache.delete(key);
return null;
}
return entry.value;
}
export function setCached(key: string, value: string, ttlMs: number): void {
cache.set(key, { value, expires: Date.now() + ttlMs });
}
Then update the route handler to use the cache:
router.get("/post/:id", async (ctx) => {
const id = ctx.params.id;
const cacheKey = `post:${id}`;
const cached = getCached(cacheKey);
if (cached) {
ctx.response.type = "text/html";
ctx.response.body = cached;
return;
}
try {
const post = await fetchPost(id);
const html = render(
<Page
title={post.title}
content={post.body}
lastUpdated={new Date().toISOString()}
data={post}
/>
);
// Cache for 60 seconds
setCached(cacheKey, html, 60_000);
ctx.response.type = "text/html";
ctx.response.body = html;
} catch (err) {
ctx.response.status = 500;
ctx.response.body = "Internal Server Error";
}
});
Implementing SSG in Deno
Static Site Generation renders all pages at build time, producing static HTML files that can be served from any static file server or CDN. This approach is perfect for blogs, documentation sites, and marketing pages where content changes infrequently.
Let's create a build script that generates static HTML files:
// build.tsx
import { render } from "preact/render-to-string";
import { Page } from "./components/Page.tsx";
interface Post {
id: number;
title: string;
body: string;
}
async function fetchAllPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
return res.json();
}
async function ensureDir(path: string) {
try {
await Deno.mkdir(path, { recursive: true });
} catch (err) {
if (!(err instanceof Deno.errors.AlreadyExists)) throw err;
}
}
async function writeHtml(filePath: string, html: string) {
await Deno.writeTextFile(filePath, html);
console.log(`Generated: ${filePath}`);
}
async function build() {
const startTime = performance.now();
const buildTime = new Date().toISOString();
await ensureDir("./dist");
await ensureDir("./dist/post");
// Generate the home page
const homeHtml = render(
<Page
title="Deno SSG Demo"
content="This page was generated at build time."
lastUpdated={buildTime}
/>
);
await writeHtml("./dist/index.html", homeHtml);
// Fetch all posts and generate a page for each
const posts = await fetchAllPosts();
// Generate posts in parallel for faster builds
await Promise.all(posts.map(async (post) => {
const html = render(
<Page
title={post.title}
content={post.body}
lastUpdated={buildTime}
data={{ id: post.id }}
/>
);
await writeHtml(`./dist/post/${post.id}.html`, html);
}));
// Generate an index listing all posts
const indexHtml = render(
<Page
title="All Posts"
content={`${posts.length} posts available`}
lastUpdated={buildTime}
data={posts.map(p => ({ id: p.id, title: p.title }))}
/>
);
await writeHtml("./dist/posts.html", indexHtml);
const elapsed = (performance.now() - startTime).toFixed(0);
console.log(`\nBuild complete in ${elapsed}ms`);
console.log(`Generated ${posts.length + 2} pages in ./dist/`);
}
await build();
Run the build with deno task build. This creates a dist/ directory with static HTML files. Now you need a server to serve these files. You can use a simple static file server:
// static-server.ts
import { Application } from "oak";
const app = new Application();
// Serve static files from the dist directory
app.use(async (ctx, next) => {
try {
const filePath = ctx.request.url.pathname;
let path = `./dist${filePath}`;
// Default to index.html for root
if (path === "./dist/") path = "./dist/index.html";
// Add .html extension if no extension present
if (!path.includes(".")) {
path += ".html";
}
await ctx.send({ root: ".", path: path.replace("./", "") });
} catch {
await next();
}
});
console.log("Static server running on http://localhost:8000");
await app.listen({ port: 8000 });
Alternatively, you can use Deno's built-in file server for quick testing:
deno run --allow-net --allow-read https://deno.land/std@0.216.0/http/file_server.ts ./dist
Implementing ISR in Deno
Incremental Static Regeneration combines the best of both worlds: static pages are served instantly from cache, but the server periodically regenerates them in the background to keep content fresh. The key insight is that users see the stale cached version immediately, while the regeneration happens asynchronously.
Here's a complete ISR implementation:
// isr-server.tsx
import { Application, Router } from "oak";
import { render } from "preact/render-to-string";
import { Page } from "./components/Page.tsx";
interface CacheEntry {
html: string;
generatedAt: number;
regenerating: boolean;
}
// In-memory cache — use Redis or a database in production
const isrCache = new Map<string, CacheEntry>();
// Default revalidation interval: 60 seconds
const REVALIDATE_AFTER_MS = 60_000;
async function fetchPost(id: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!res.ok) throw new Error("Failed to fetch post");
return res.json();
}
async function regeneratePage(key: string, id: string) {
const entry = isrCache.get(key);
if (entry?.regenerating) return; // Already regenerating
if (entry) entry.regenerating = true;
try {
const post = await fetchPost(id);
const html = render(
<Page
title={post.title}
content={post.body}
lastUpdated={new Date().toISOString()}
data={post}
/>
);
isrCache.set(key, {
html,
generatedAt: Date.now(),
regenerating: false,
});
console.log(`[ISR] Regenerated page: ${key}`);
} catch (err) {
console.error(`[ISR] Regeneration failed for ${key}:`, err.message);
if (entry) entry.regenerating = false;
}
}
const app = new Application();
const router = new Router();
router.get("/post/:id", async (ctx) => {
const id = ctx.params.id;
const cacheKey = `/post/${id}`;
const entry = isrCache.get(cacheKey);
const now = Date.now();
if (!entry) {
// First request — generate synchronously (cold start)
console.log(`[ISR] Cold start for ${cacheKey}`);
try {
const post = await fetchPost(id);
const html = render(
<Page
title={post.title}
content={post.body}
lastUpdated={new Date().toISOString()}
data={post}
/>
);
isrCache.set(cacheKey, {
html,
generatedAt: now,
regenerating: false,
});
ctx.response.type = "text/html";
ctx.response.body = html;
} catch {
ctx.response.status = 500;
ctx.response.body = "Error generating page";
}
return;
}
// Serve cached content immediately
ctx.response.type = "text/html";
ctx.response.body = entry.html;
// Check if background regeneration is needed
const age = now - entry.generatedAt;
if (age > REVALIDATE_AFTER_MS && !entry.regenerating) {
// Fire and forget — don't await
regeneratePage(cacheKey, id);
}
});
// Endpoint to manually trigger regeneration (on-demand ISR)
router.post("/api/revalidate/:id", async (ctx) => {
const id = ctx.params.id;
const cacheKey = `/post/${id}`;
// Optional: verify a secret token for security
const secret = ctx.request.headers.get("x-revalidate-token");
if (secret !== Deno.env.get("REVALIDATE_TOKEN")) {
ctx.response.status = 401;
ctx.response.body = { error: "Unauthorized" };
return;
}
await regeneratePage(cacheKey, id);
ctx.response.body = { success: true, message: `Revalidated ${cacheKey}` };
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log("ISR server running on http://localhost:8000");
console.log(`Revalidation interval: ${REVALIDATE_AFTER_MS / 1000}s`);
await app.listen({ port: 8000 });
The ISR flow works as follows: the first request to a page generates it synchronously and caches the result. Subsequent requests serve the cached HTML instantly. When the cache age exceeds the revalidation interval, the next request still gets the stale cache, but triggers a background regeneration. The following request after regeneration completes will serve the fresh content.
On-Demand Revalidation
Besides time-based revalidation, ISR supports on-demand revalidation. This is useful when content changes are triggered by external events (e.g., a CMS publish webhook). The POST /api/revalidate/:id endpoint above demonstrates this. You can trigger it like so:
curl -X POST http://localhost:8000/api/revalidate/1 \
-H "x-revalidate-token: your-secret-token"
Using Fresh: Deno's Official Full-Stack Framework
While building from scratch is educational, Deno's Fresh framework provides all three rendering strategies out of the box with a file-based routing system. Here's how to get started:
deno run -A -r https://fresh.deno.dev my-fresh-app
cd my-fresh-app
deno task start
Fresh uses an islands architecture for client-side interactivity, rendering most pages as static HTML on the server. Here's an example of a Fresh route with ISR-style caching:
// routes/posts/[id].tsx
import { PageProps } from "$fresh/server.ts";
interface Post {
id: number;
title: string;
body: string;
}
export const config = {
// Enable ISR with a 60-second revalidation interval
routeOverride: "/posts/:id",
};
// This function runs on the server
export const handler: Handlers<Post> = {
async GET(_req, ctx) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${ctx.params.id}`
);
const post = await res.json();
return ctx.render(post);
},
};
export default function PostPage({ data }: PageProps<Post>) {
return (
<main>
<h1>{data.title}</h1>
<p>{data.body}</p>
</main>
);
}
Best Practices
- Choose the right strategy per route: Not every page needs SSR. Use SSG for content that rarely changes (about pages, blog posts), SSR for highly personalized or real-time pages, and ISR for pages that update periodically but don't need per-request freshness.
- Implement proper error handling: Always handle data fetch failures gracefully. For ISR, serve stale content if regeneration fails rather than showing an error page.
- Use a persistent cache in production: The in-memory cache examples in this tutorial work for single-instance deployments. For production, use Redis, Deno KV, or a database to share cache across instances.
- Set appropriate cache headers: Combine server-side caching with HTTP cache headers (
Cache-Control,ETag,Stale-While-Revalidate) for layered caching at the CDN and browser level. - Monitor regeneration: Log cache hit/miss ratios and regeneration times. Set up alerts for regeneration failures so stale content doesn't persist unnoticed.
- Secure revalidation endpoints: Always protect on-demand revalidation endpoints with authentication tokens or API keys to prevent abuse.
- Optimize data fetching: Batch API calls where possible, use streaming for large datasets, and consider GraphQL or RPC for more efficient data loading.
- Hydrate selectively: If you add client-side interactivity, only hydrate the components that need it. Fresh's islands architecture does this automatically; in custom setups, be deliberate about what JavaScript you ship to the client.
Conclusion
Deno provides a powerful and ergonomic foundation for implementing all three major rendering strategies. SSR gives you real-time, personalized content with excellent SEO. SSG delivers maximum performance for static content. ISR bridges the gap by serving cached pages instantly while keeping them fresh through background regeneration. By understanding the trade-offs of each approach and applying them strategically across your routes, you can build applications that are fast, SEO-friendly, and always up to date. Whether you build from scratch with Oak and Preact or leverage the Fresh framework's built-in capabilities, Deno's ecosystem gives you everything you need to ship production-grade rendered applications with minimal configuration and maximum developer productivity.