Skip to content

Shipping Fresh on Deno: SSR, islands, and performance

5 min read

A practical walkthrough of building and shipping a modern site with Deno Fresh, covering SSR, islands architecture, and performance wins.

Fresh brings a server-first approach with minimal JavaScript by default and a small, explicit set of interactive "islands." For personal sites, docs, blogs, and many product surfaces, this pays off with fast TTFB, straightforward data flow, and fewer client-side surprises.

This post walks through practical patterns I use to build and ship Fresh apps, with examples you can adapt directly.

The server‑first mindset

  • Render as much as possible on the server (SSR).
  • Send only critical HTML/CSS. Hydrate specific islands that need interactivity.
  • Prefer simple file-based routing and small, explicit islands over large bundled apps.

This keeps your bundles small and your app fast by default.

Pages, layouts, and islands

A common structure:

  • Layout: provides head tags, shared header/footer, and shell.
  • Routes: server-rendered pages (fast, cached, predictable).
  • Islands: opt-in hydration for small interactive widgets.
tsx
// routes/_app.tsx
import { define } from "../utils.ts";

export default define.page(function App({ Component }) {
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>PatrickChoDev</title>
        <link rel="icon" href="/favicon.ico" />
      </head>
      <body>
        <Component />
      </body>
    </html>
  );
});
tsx
// components/layout/SiteLayout.tsx
import { Head } from "fresh/runtime";
import type { ComponentChildren } from "preact";

export default function SiteLayout(
  { title, description, children }: {
    title?: string;
    description?: string;
    children?: ComponentChildren;
  },
) {
  const pageTitle = title ? `${title}` : "PatrickChoDev";
  return (
    <>
      <Head>
        <title>{pageTitle}</title>
        <meta name="description" content={description ?? ""} />
      </Head>
      <div class="min-h-screen flex flex-col">
        <header class="border-b">
          <nav class="mx-auto max-w-6xl px-4 h-16 flex items-center gap-2">
            <a href="/" class="font-bold">PatrickChoDev</a>
            <a href="/collections" class="px-3 py-2 rounded-md">Collections</a>
            <a href="/blog" class="px-3 py-2 rounded-md">Blog</a>
            <a href="/playground" class="px-3 py-2 rounded-md">Playground</a>
            <a href="/contact" class="px-3 py-2 rounded-md">Contact</a>
          </nav>
        </header>
        <main class="flex-1">
          {children}
        </main>
        <footer class="border-t">
          <div class="mx-auto max-w-6xl px-4 py-6 text-sm text-gray-600">
            © {new Date().getFullYear()} PatrickChoDev
          </div>
        </footer>
      </div>
    </>
  );
}
tsx
// islands/Counter.tsx
import { useState } from "preact/hooks";

export default function Counter() {
  const [n, setN] = useState(0);
  return (
    <div class="flex items-center gap-3">
      <button onClick={() => setN((x) => x - 1)} class="border px-2">-1</button>
      <span class="tabular-nums">{n}</span>
      <button onClick={() => setN((x) => x + 1)} class="border px-2">+1</button>
    </div>
  );
}

Use islands sparingly for specific widgets (forms, counters, small viewers) rather than whole pages.

Data fetching

In Fresh, the server route is the natural place to fetch data. Keep fetches close to the route, transform data, and render HTML. If you need dynamic behavior, hydrate a small island that consumes that server-rendered data.

tsx
// routes/collections/index.tsx
import { define } from "../../utils.ts";
import SiteLayout from "../../components/layout/SiteLayout.tsx";

type Collection = {
  title: string;
  description: string;
  tags: string[];
  year?: number;
};

export const handler = define.handlers({
  async GET(_req, ctx) {
    // Example: fetch or read local JSON/DB
    const collections: Collection[] = [
      {
        title: "Site",
        description: "Fresh + Preact",
        tags: ["Deno", "Fresh"],
        year: 2025,
      },
      {
        title: "CLI",
        description: "Cross-platform tool",
        tags: ["Deno", "CLI"],
        year: 2023,
      },
    ];
    return ctx.render(collections);
  },
});

export default define.page<{ data: Collection[] }>(function Collections(ctx) {
  const collections = ctx.data ?? [];
  return (
    <SiteLayout
      title="Collections — PatrickChoDev"
      description="Selected software collections."
    >
      <section class="mx-auto max-w-6xl px-4 py-12">
        <h1 class="text-3xl font-bold">Collections</h1>
        <ul class="grid gap-4 mt-6 sm:grid-cols-2 lg:grid-cols-3">
          {collections.map((p) => (
            <li class="border rounded-lg p-4 bg-white">
              <div class="flex items-start justify-between">
                <h2 class="font-semibold">{p.title}</h2>
                {p.year && <span class="text-xs text-gray-500">{p.year}</span>}
              </div>
              <p class="mt-2 text-sm text-gray-700">{p.description}</p>
              <div class="mt-3 flex gap-2 flex-wrap">
                {p.tags.map((t) => (
                  <span class="text-xs border rounded px-2 py-0.5">{t}</span>
                ))}
              </div>
            </li>
          ))}
        </ul>
      </section>
    </SiteLayout>
  );
});

This keeps your data access secure (server-only) and avoids over-fetching on the client.

Styling and assets

Tailwind (via the official Vite plugin) works great with Fresh. Keep base styles minimal and component-focused. Avoid global CSS where possible; use small, reusable classes.

tsx
// vite.config.ts
import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [fresh(), tailwindcss()],
});
css
/* assets/styles.css */
@import "tailwindcss";

:root {
  color-scheme: light;
}

.prose pre code {
  white-space: pre;
}

Accessibility first

  • Prefer semantic HTML and headings.
  • Use proper label/aria attributes for interactive elements.
  • Ensure keyboard operability and focus states.
  • Inline SVGs and landmark roles help clarify structure.
tsx
// components/layout/SiteLayout.tsx (nav snippet)
<nav aria-label="Primary">
  <a href="/" class="px-3 py-2 rounded-md" aria-current="page">Home</a>
  <a href="/collections" class="px-3 py-2 rounded-md">Collections</a>
  <a href="/blog" class="px-3 py-2 rounded-md">Blog</a>
  <a href="/playground" class="px-3 py-2 rounded-md">Playground</a>
  <a href="/contact" class="px-3 py-2 rounded-md">Contact</a>
</nav>;

For the active page, set aria-current="page" and add a clear visual style (contrast-compliant). If applying server-side, compare the current path with nav links and style accordingly.

Islands where it matters

Keep islands tiny. For example, an embedded code playground, a light/dark toggle, or an inline filter. Ship only the code that needs to run on the client.

tsx
// islands/ThemeToggle.tsx
import { useEffect, useState } from "preact/hooks";

export default function ThemeToggle() {
  const [dark, setDark] = useState(false);
  useEffect(() => {
    document.documentElement.classList.toggle("dark", dark);
  }, [dark]);
  return (
    <button
      class="border px-3 py-1 rounded-md"
      onClick={() => setDark((d) => !d)}
      aria-pressed={dark}
    >
      {dark ? "Dark" : "Light"}
    </button>
  );
}

Performance wins

  • Server render by default: fast TTFB, HTML-first delivery.
  • Small JS payloads: only hydrate islands.
  • Cache static assets aggressively.
  • Add basic security/SEO headers centrally.
ts
// main.ts (middleware snippet)
app.use(async (ctx) => {
  const res = await ctx.next();
  const h = res.headers;
  if (!h.has("X-Content-Type-Options")) {
    h.set("X-Content-Type-Options", "nosniff");
  }
  if (!h.has("Referrer-Policy")) {
    h.set("Referrer-Policy", "strict-origin-when-cross-origin");
  }
  if (!h.has("X-Frame-Options")) h.set("X-Frame-Options", "DENY");
  if (!h.has("Permissions-Policy")) {
    h.set("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
  }
  return res;
});

Deployment

Deno Deploy makes it straightforward:

bash
# Build (if you have a build step)
deno task build

# Serve locally (Fresh)
deno task dev

# For Deploy: link your repo and let CI push to Deno Deploy

Alternatively, any host that can run deno serve works:

bash
deno serve -A _fresh/server.js

A practical checklist

  • SSR pages with minimal CSS
  • Explicit islands only where necessary
  • Accessible nav, headings, labels
  • Security headers and robots/sitemap
  • Small bundle sizes and fast TTFB
  • Clear collection structure and utilities

Closing thoughts

Fresh helps you ship the right defaults: fast, simple, and maintainable. Build your pages on the server, hydrate only where needed, and keep interactivity focused and minimal.

If you have questions or want a deeper dive into architecture trade-offs, caching tiers, or DX tooling, reach out on the contact page.