← Back to DevBytes

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

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

When building modern web applications, choosing the right rendering strategy can dramatically affect performance, SEO, and developer experience. Elysia, the ergonomic Bun-native web framework, is fast enough to handle all three major rendering paradigms — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — without breaking a sweat. In this tutorial, you'll learn what each strategy means, when to use it, and how to implement all three using Elysia.

Why Rendering Strategy Matters

Rendering is the act of turning your application's data and templates into HTML the browser can display. Where and when that HTML is produced has real consequences:

The three strategies we'll cover sit on a spectrum between fully dynamic and fully static. Elysia's plugin architecture and raw speed make it a great host for all of them.

Setting Up the Project

Make sure you have Bun installed, then scaffold a new project:

bun create elysia ssr-elysia
cd ssr-elysia
bun add @elysiajs/html html

The @elysiajs/html plugin lets Elysia return HTML strings with the correct Content-Type header and adds JSX support. We'll use JSX for templating because it's concise and type-safe.

Create a basic server in src/index.ts:

import { Elysia } from 'elysia'
import { html } from '@elysiajs/html'

const app = new Elysia()
  .use(html())
  .get('/', () => <h1>Hello from Elysia</h1>)
  .listen(3000)

console.log(`Listening on http://localhost:3000`)

Run it with bun src/index.ts. You now have a working foundation for all three rendering strategies.

Server-Side Rendering (SSR)

What Is SSR?

SSR means the server generates a fresh HTML document on every request, using the latest data. The browser receives a complete page that's ready to display immediately. This is ideal for authenticated dashboards, personalized feeds, and any page whose content changes per user or per request.

Implementing SSR in Elysia

Let's build a small blog that fetches a post from a (mock) database and renders it server-side on each request.

import { Elysia, t } from 'elysia'
import { html } from '@elysiajs/html'

type Post = { id: number; title: string; body: string }

async function getPost(id: number): Promise<Post | null> {
  // In real life, this would be a DB query.
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
  if (!res.ok) return null
  return res.json()
}

function Layout({ children }: { children: any }) {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Elysia SSR Blog</title>
      </head>
      <body>
        <nav><a href="/">Home</a></nav>
        {children}
      </body>
    </html>
  )
}

const app = new Elysia()
  .use(html())
  .get('/post/:id', async ({ params, set }) => {
    const post = await getPost(Number(params.id))
    if (!post) {
      set.status = 404
      return <Layout><h1>Not found</h1></Layout>
    }
    return (
      <Layout>
        <article>
          <h1>{post.title}</h1>
          <p>{post.body}</p>
        </article>
      </Layout>
    )
  }, {
    params: t.Object({ id: t.String() })
  })
  .listen(3000)

Every time a user hits /post/42, Elysia fetches the post, renders the JSX to HTML, and streams it back. The page is always fresh — but you pay the cost of the upstream fetch on every request.

When to Use SSR

Static Site Generation (SSG)

What Is SSG?

SSG pre-renders pages to static HTML files at build time. The server (or CDN) then serves those files with no computation per request. This gives you the best possible performance and the cheapest hosting bill, at the cost of freshness: changes require a rebuild.

Implementing SSG with Elysia

The trick is to reuse your route handlers as render functions, then write their output to disk. We'll create a small build script that imports the handlers and emits HTML files.

First, refactor handlers so they can be called outside of a request context:

// src/render.ts
import { html } from '@elysiajs/html'

export type Post = { id: number; title: string; body: string }

export async function renderPost(post: Post): Promise<string> {
  return (
    <html lang="en">
      <head><title>{post.title}</title></head>
      <body>
        <article>
          <h1>{post.title}</h1>
          <p>{post.body}</p>
        </article>
      </body>
    </html>
  ) as unknown as string
}

Now write the build script:

// scripts/build.ts
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { renderPost, Post } from '../src/render'

async function fetchAllPosts(): Promise<Post[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts')
  return res.json()
}

async function build() {
  const outDir = join(process.cwd(), 'public')
  await mkdir(join(outDir, 'posts'), { recursive: true })

  const posts = await fetchAllPosts()
  for (const post of posts) {
    const html = await renderPost(post)
    await writeFile(join(outDir, 'posts', `${post.id}.html`), html)
  }

  console.log(`Generated ${posts.length} static pages`)
}

build()

Run bun scripts/build.ts. You now have a public/posts/*.html directory full of pre-rendered pages. Serve them with Elysia's static plugin:

import { Elysia } from 'elysia'
import { staticPlugin } from '@elysiajs/static'

new Elysia()
  .use(staticPlugin({ assets: 'public', prefix: '/' }))
  .listen(3000)

Requests now hit the filesystem directly — no template rendering, no upstream API calls. You can deploy the public folder to any CDN.

When to Use SSG

Incremental Static Regeneration (ISR)

What Is ISR?

ISR is the middle ground: pages are rendered once and cached, but the cache is refreshed in the background after a configurable time-to-live (TTL). The first visitor after the TTL expires gets the stale page instantly while a fresh version is regenerated for the next visitor. You get SSG-like speed with SSR-like freshness.

Implementing ISR in Elysia

Elysia doesn't ship ISR out of the box, but it's straightforward to build with an in-memory cache and a background refresh. For production, swap the Map for Redis or a similar store.

import { Elysia, t } from 'elysia'
import { html } from '@elysiajs/html'

type Post = { id: number; title: string; body: string }

type CacheEntry = {
  html: string
  generatedAt: number
  refreshing?: boolean
}

const cache = new Map<string, CacheEntry>()
const TTL_MS = 60_000 // 1 minute

async function fetchPost(id: number): Promise<Post | null> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
  if (!res.ok) return null
  return res.json()
}

function renderPost(post: Post): string {
  return (
    <html lang="en">
      <head><title>{post.title}</title></head>
      <body>
        <article>
          <h1>{post.title}</h1>
          <p>{post.body}</p>
          <small>Rendered at {new Date().toISOString()}</small>
        </article>
      </body>
    </html>
  ) as unknown as string
}

async function regenerate(id: number) {
  const post = await fetchPost(id)
  if (!post) return
  cache.set(`post:${id}`, {
    html: renderPost(post),
    generatedAt: Date.now()
  })
}

const app = new Elysia()
  .use(html())
  .get('/post/:id', async ({ params, set }) => {
    const key = `post:${params.id}`
    const entry = cache.get(key)

    // Cache miss: render synchronously (first hit pays the cost)
    if (!entry) {
      const post = await fetchPost(Number(params.id))
      if (!post) {
        set.status = 404
        return <h1>Not found</h1>
      }
      const html = renderPost(post)
      cache.set(key, { html, generatedAt: Date.now() })
      return html
    }

    // Stale but not yet refreshing: trigger background refresh
    const isStale = Date.now() - entry.generatedAt > TTL_MS
    if (isStale && !entry.refreshing) {
      entry.refreshing = true
      // Fire and forget — don't block the response
      regenerate(Number(params.id)).finally(() => {
        const next = cache.get(key)
        if (next) next.refreshing = false
      })
    }

    // Always serve the cached HTML immediately
    return entry.html
  }, {
    params: t.Object({ id: t.String() })
  })
  .listen(3000)

Here's what happens on each request:

On-Demand Revalidation

Sometimes you don't want to wait for the TTL — for example, when a post is edited in your CMS. Add a webhook endpoint that invalidates a specific page:

.post('/api/revalidate', async ({ body, set }) => {
  const { id, secret } = body as { id: string; secret: string }
  if (secret !== process.env.REVALIDATE_SECRET) {
    set.status = 401
    return 'Unauthorized'
  }
  await regenerate(Number(id))
  return 'OK'
}, {
  body: t.Object({
    id: t.String(),
    secret: t.String()
  })
})

Your CMS can now POST to /api/revalidate whenever content changes, giving you the best of both worlds: instant updates when needed, automatic background refresh otherwise.

When to Use ISR

Best Practices

Separate Rendering from Data Fetching

Keep your render functions pure: they take data and return HTML. Fetch data in route handlers or dedicated service modules. This separation is what lets you reuse the same render function for SSR, SSG, and ISR — as we did with renderPost above.

Choose the Right Strategy Per Route

You don't have to pick one strategy for the whole app. A common pattern:

Elysia's per-route design makes this trivial — each handler decides how it produces HTML.

Use a Real Cache in Production

The in-memory Map in our ISR example is great for development but won't survive restarts or scale across multiple instances. Use Redis, Upstash, or your CDN's edge cache for production. The logic stays the same; only the storage layer changes.

Stream Where You Can

Elysia supports streaming responses. For complex SSR pages, consider streaming HTML in chunks so the browser can start painting before the server finishes rendering. This improves perceived performance even when total render time is unchanged.

Validate Inputs

Always use Elysia's t.Object schema validation on route params and bodies. It prevents injection bugs and gives you typed values inside handlers — especially important when those handlers feed into HTML output.

Measure Before You Optimize

Don't assume SSR is slow or SSG is always faster. Use Elysia's built-in tracing or a tool like autocannon to measure real response times for your workload. Often, Elysia's raw speed makes SSR perfectly viable even at scale, and the simplicity of always-fresh pages beats the operational overhead of cache invalidation.

Conclusion

Elysia's combination of Bun-native performance, JSX templating, and a flexible plugin system makes it an excellent foundation for any rendering strategy. SSR gives you always-fresh, personalized pages; SSG gives you blazing-fast, CDN-friendly static output; and ISR bridges the gap with cached pages that refresh in the background. By understanding the trade-offs and reusing render functions across strategies, you can build applications that are fast, SEO-friendly, and operationally simple — all from a single, ergonomic framework. Start with SSR for correctness, reach for SSG where content is stable, and adopt ISR when you need both speed and freshness without the complexity of full SSR at the edge.

— Ad —

Google AdSense will appear here after approval

← Back to all articles