Fresh 2.3: Zero JS by default, View Transitions, and Temporal support | DenoSkip to main contentIntroducing Claw Patrol: an open-source security firewall for agents<br>Read the post-><br>Dismiss
Fresh 2.3: Zero JS by default, View Transitions, and Temporal support<br>April 24, 2026<br>Bartek Iwańczuk<br>Product Update<br>Fresh
Fresh 2.3 is out, with over 100 commits from 20 contributors. This release makes<br>the "zero JavaScript by default" promise actually hold, adds View Transitions<br>support, pre-compiles middleware chains, and rounds out a long list of Vite<br>integration fixes.
You can start a new project with:
deno create @fresh/init<br>or update an existing one with:
deno run -Ar jsr:@fresh/update<br>Zero JavaScript by default
Fresh has always said that pages ship no JavaScript unless they need to, but<br>that wasn’t strictly true. Every page ended up with a small client-entry<br>script to bootstrap the island reviver and partials engine, even when neither<br>was used.
Thanks to Jeroen Akkerman in<br>#3696, Fresh now checks whether<br>the page actually uses islands or partials (f-client-nav) before injecting<br>anything. If it doesn’t, the page ships with no tag, no module<br>preload headers, and no client-side bundle at all.
Fresh 2.2<br>Fresh 2.3
JavaScript (raw)<br>~14–22 KB<br>0 KB
JavaScript (gzip)<br>~5–9 KB<br>0 KB
Module preload headers<br>1+
Inline boot
There is nothing to configure. Static pages will just stop shipping JavaScript<br>after upgrading.
View Transitions
The<br>View Transitions API<br>lets browsers animate between DOM states natively. Fresh 2.3 wires it up to the<br>existing partials system, so you can opt in by adding one attribute:
body f-client-nav f-view-transition>
body><br>Partial navigations will then be wrapped in document.startViewTransition() and<br>you can customize the animation with regular CSS:
::view-transition-old(root) {<br>animation: fade-out 0.2s ease-in;<br>::view-transition-new(root) {<br>animation: fade-in 0.2s ease-out;<br>Or target individual elements:
.sidebar {<br>view-transition-name: sidebar;
View Transitions between two pages in a Fresh app.
Browsers without support fall back to normal partial updates. See the<br>View Transitions docs<br>for more.
First-class WebSocket support
Fresh now has built-in WebSocket support<br>(#3774). The quickest way to add<br>a WebSocket endpoint is app.ws():
main.ts<br>const app = new App()<br>.ws("/ws", {<br>open(socket) {<br>console.log("Client connected");<br>},<br>message(socket, event) {<br>socket.send(`Echo: ${event.data}`);<br>},<br>close(socket) {<br>console.log("Client disconnected");<br>},<br>});<br>For file-based routes, use ctx.upgrade() inside a GET handler. In managed<br>mode, pass handlers and get the response back directly:
routes/api/ws.ts<br>export const handlers = define.handlers({<br>GET(ctx) {<br>return ctx.upgrade({<br>message(socket, event) {<br>socket.send(`Echo: ${event.data}`);<br>},<br>});<br>},<br>});<br>There’s also a bare mode. Call ctx.upgrade() without arguments to get the raw<br>WebSocket object, useful when you need to store sockets in a shared structure<br>like a chat room:
routes/api/chat.ts<br>const clients = new SetWebSocket>();
export const handlers = define.handlers({<br>GET(ctx) {<br>const { socket, response } = ctx.upgrade();
socket.onopen = () => clients.add(socket);<br>socket.onmessage = (event) => {<br>for (const client of clients) {<br>if (client.readyState === WebSocket.OPEN) {<br>client.send(event.data);<br>};<br>socket.onclose = () => clients.delete(socket);
return response;<br>},<br>});<br>Non-WebSocket requests to a WebSocket route automatically get a 400 response.<br>See the<br>WebSocket documentation for<br>the full API including idleTimeout and protocol options.
Vite integration improvements
A lot of this cycle went into making the Vite integration more robust,<br>especially around npm package compatibility.
CJS-to-ESM transforms and process.env replacements are now handled by Vite<br>directly, so we could drop two Babel passes from the build.
CJS handling in SSR dev mode has been improved, React compat aliasing works<br>properly now, and resolve.alias is applied before Deno resolution. Packages<br>like Radix UI work out of the box.
optimizeDeps.exclude is set up so Vite no longer creates a duplicate Preact<br>instance during pre-bundling.
Vite asset URLs now include a cache-bust query param so immutable caching does<br>the right thing (#3761).
Temp files are ignored by the Vite watcher, so editor swap files no longer<br>crash the dev server (#3763).
Work in this area is continuing.<br>#3767 removes the rest of the<br>Babel transforms (~2,050 lines) and lets Vite handle CJS packages natively end<br>to end. That should land shortly after 2.3.
CSP nonces and IP filtering
Two new security middleware ship with this release.
CSP nonce injection<br>(#3709) generates a unique<br>nonce per request and adds it to every inline and tag.<br>The corresponding Content-Security-Policy header uses 'nonce-{value}'<br>instead of 'unsafe-inline', so only scripts and styles that Fresh rendered are<br>allowed to execute.
main.ts<br>import { csp } from "fresh";
app.use(csp({ useNonce: true }));<br>User-supplied CSP directives now...