Deno 2 Fresh Framework Full-Stack Guide
The Deno Fresh framework takes a radically different approach to web development. Instead of shipping megabytes of JavaScript to the browser, Fresh renders everything on the server and only hydrates interactive “islands” of UI. Combined with Deno 2’s native TypeScript support, npm compatibility, and zero-config deployment, Fresh offers one of the most productive full-stack development experiences available in 2026.
Specifically, this guide covers building a real application with Fresh from scratch — routing, layouts, islands, forms, database integration, and deployment. Along the way, you will see how Fresh eliminates the build step entirely while delivering strong performance out of the box, and, importantly, where its model genuinely does not fit.
Why Fresh in 2026
The JavaScript ecosystem has been steadily moving toward server-first rendering. Next.js has Server Components, Remix has loaders, and Astro has content-first rendering. Fresh takes this further by sending zero JavaScript by default. Every page is server-rendered HTML, and interactive components must be explicitly opted in as “islands,” which means you consciously choose what ships to the client rather than shipping the whole app and trimming later.
The result is dramatically smaller page weights. According to Fresh’s documentation, a typical static page ships no JavaScript at all, compared with the 200+ KB that a comparable React SPA commonly downloads before it can render anything. Moreover, Fresh uses Preact (roughly 3 KB) instead of React (roughly 40 KB) for its islands, so even interactive pages stay lightweight.
Setting Up a Fresh Project
With Deno 2 installed, creating a new Fresh project takes a single command. Notably, there is no node_modules, no package.json, and no build configuration to maintain:
# Install Deno 2 (if not already installed)
curl -fsSL https://deno.land/install.sh | sh
# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app
# Start the dev server (no build step!)
deno task start
Furthermore, the project structure follows file-system routing similar to Next.js:
my-app/
├── routes/
│ ├── index.tsx # / route
│ ├── about.tsx # /about route
│ ├── api/
│ │ └── posts.ts # /api/posts API route
│ ├── blog/
│ │ ├── index.tsx # /blog route
│ │ └── [slug].tsx # /blog/:slug dynamic route
│ └── _layout.tsx # Shared layout
├── islands/
│ ├── Counter.tsx # Interactive island component
│ └── SearchBar.tsx # Client-side search
├── components/
│ ├── Header.tsx # Server-only component
│ └── Footer.tsx # Server-only component
├── static/
│ └── styles.css
├── fresh.config.ts
└── deno.json
Routes and Server-Side Rendering
Every file in the routes/ directory becomes a route. The Fresh framework uses handler functions for data loading and component functions for rendering — conceptually similar to Remix loaders. Because the handler runs only on the server, you can talk to a database or read a secret directly, with no risk of leaking it to the client:
// routes/blog/[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { db } from "../../utils/db.ts";
interface BlogPost {
title: string;
content: string;
publishedAt: Date;
author: string;
}
export const handler: Handlers = {
async GET(_req, ctx) {
const post = await db.query(
"SELECT * FROM posts WHERE slug = $1",
[ctx.params.slug]
);
if (!post) {
return ctx.renderNotFound();
}
return ctx.render(post);
},
};
export default function BlogPostPage({ data }: PageProps) {
return (
{data.title}
By {data.author} on {new Date(data.publishedAt).toLocaleDateString()}
);
}
Middleware and Shared Layouts
Moreover, beyond individual routes, Fresh supports middleware via _middleware.ts files that wrap every route in a directory. This is the natural home for authentication, request logging, or attaching a request-scoped database connection to ctx.state. Because middleware composes by folder depth, you can scope an auth check to routes/admin/ without touching the public pages beside it:
// routes/admin/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
export async function handler(req: Request, ctx: FreshContext) {
const session = getSession(req);
if (!session) {
return new Response("", {
status: 302,
headers: { Location: "/login" },
});
}
ctx.state.user = session.user; // available to nested handlers
return await ctx.next();
}
Similarly, a _layout.tsx wraps child routes in shared chrome — a header, footer, or container — while still rendering entirely on the server.
Islands: Selective Client-Side Interactivity
Islands are the core innovation of Fresh. Any component placed in the islands/ directory gets hydrated on the client; everything else stays as static HTML. This is how you add interactivity without shipping a full framework to the browser. Importantly, islands receive only JSON-serializable props, because those props are serialized into the page and rehydrated — so you cannot pass a function or a class instance across the boundary:
// islands/SearchBar.tsx
import { useSignal } from "@preact/signals";
interface SearchResult {
title: string;
slug: string;
excerpt: string;
}
export default function SearchBar() {
const query = useSignal("");
const results = useSignal([]);
const isLoading = useSignal(false);
const handleSearch = async (value: string) => {
query.value = value;
if (value.length < 2) {
results.value = [];
return;
}
isLoading.value = true;
const res = await fetch(
`/api/search?q=${encodeURIComponent(value)}`
);
results.value = await res.json();
isLoading.value = false;
};
return (
handleSearch(e.currentTarget.value)}
class="w-full px-4 py-2 border rounded-lg"
/>
{results.value.length > 0 && (
{results.value.map((r) => (
-
{r.title}
{r.excerpt}
))}
)}
);
}
Then, use the island in any route by importing it like a regular component:
// routes/index.tsx
import SearchBar from "../islands/SearchBar.tsx";
import Header from "../components/Header.tsx";
export default function Home() {
return (
{/* Server-rendered, zero JS */}
{/* Hydrated island, ships JS */}
{/* Server-rendered, zero JS */}
);
}
One subtlety worth internalizing: an island hydrates independently, so it cannot share Preact context with the static page around it. If two islands need to coordinate — say a cart button and a cart drawer — they must communicate through a shared signal module or the URL, not through a common parent component. Keeping islands small and self-contained avoids this friction entirely.
Forms and Mutations
Fresh handles form submissions server-side, much like traditional web frameworks. As a result, the happy path needs no client-side JavaScript at all, and the form still works if scripts fail to load — genuine progressive enhancement:
// routes/contact.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
interface FormState {
success?: boolean;
error?: string;
}
export const handler: Handlers = {
GET(_req, ctx) {
return ctx.render({});
},
async POST(req, ctx) {
const form = await req.formData();
const name = form.get("name")?.toString();
const email = form.get("email")?.toString();
const message = form.get("message")?.toString();
if (!name || !email || !message) {
return ctx.render({ error: "All fields are required" });
}
await db.query(
"INSERT INTO contacts (name, email, message) VALUES ($1, $2, $3)",
[name, email, message]
);
return ctx.render({ success: true });
},
};
export default function Contact({ data }: PageProps) {
return (
);
}
Deployment with Zero Build Step
Finally, because Fresh runs TypeScript directly, deployment is unusually simple. On Deno Deploy you connect a GitHub repository, point it at main.ts, and every push goes live on a global edge network without a CI build stage. Alternatively, the same project runs in a container with a minimal Dockerfile — useful when you need to colocate the app with a self-hosted database:
FROM denoland/deno:alpine
WORKDIR /app
COPY . .
RUN deno cache main.ts # warm the dependency cache at build time
EXPOSE 8000
CMD ["deno", "run", "-A", "main.ts"]
Either way, there is no bundler config, no node_modules to ship, and no webpack or Vite step to debug — the dependency graph is resolved from URL and npm imports at cache time.
When NOT to Use Fresh
Fresh is not the right choice for heavily interactive single-page applications where most of the screen is dynamic — dashboards, real-time collaboration tools, or design editors. In those cases the island model creates many hydration boundaries that cannot easily share state, and a traditional SPA framework like React or Solid is more ergonomic.
Additionally, if your team is deeply invested in the React ecosystem — component libraries, state managers, and testing tools — moving to Preact-based islands means losing access to some React-only packages. The npm compatibility in Deno 2 closes much of that gap, yet not every React library behaves correctly under Preact, so validate your critical dependencies before committing. In short, choose Fresh when content and SEO lead and interactivity is sprinkled in; reach for an SPA when interactivity is the product.
Key Takeaways
- The Deno Fresh framework ships zero JavaScript by default, hydrating only interactive islands on the client.
- File-system routing with handler functions cleanly separates server-side data loading from rendering.
- Middleware and layouts compose by folder depth, scoping auth and chrome without touching sibling routes.
- Islands receive only serializable props and hydrate independently, so coordinate them via shared signals, not context.
- Forms work without JavaScript using standard POST handlers, giving real progressive enhancement.
- Deployment skips the build step entirely — straight from TypeScript to Deno Deploy or a tiny container.
Related Reading
External Resources
In conclusion, the Deno Fresh framework rewards teams that put content, speed, and SEO first while keeping interactivity deliberate. By applying the routing, island, and form patterns covered in this guide — and respecting the boundaries where an SPA fits better — you can build fast, maintainable full-stack applications. Start with the fundamentals, measure your shipped JavaScript, and let the island model keep your pages lean.