← Back to DevBytes

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

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles