{"slug": "fresh-2-3-zero-js-by-default-view-transitions-and-temporal-support", "title": "Fresh 2.3: Zero JS by default, View Transitions, and Temporal support", "summary": "Here is a factual summary of the article:\n\nFresh 2.3, a web framework for Deno, has been released with over 100 commits from 20 contributors. The update delivers on its \"zero JavaScript by default\" promise by ensuring pages with no interactive islands or partials ship without any client-side scripts, and it adds built-in support for the View Transitions API and WebSockets. Additionally, the release includes significant Vite integration improvements, such as dropping Babel transforms for better npm package compatibility and fixing various caching and development server issues.", "body_md": "Fresh 2.3: Zero JS by default, View Transitions, and Temporal support\nFresh 2.3 is out, with over 100 commits from 20 contributors. This release makes the “zero JavaScript by default” promise actually hold, adds View Transitions support, pre-compiles middleware chains, and rounds out a long list of Vite integration fixes.\nYou can start a new project with:\ndeno create @fresh/init\nor update an existing one with:\ndeno run -Ar jsr:@fresh/update\nZero JavaScript by default\nFresh has always said that pages ship no JavaScript unless they need to, but\nthat wasn’t strictly true. Every page ended up with a small client-entry\nscript to bootstrap the island reviver and partials engine, even when neither\nwas used.\nThanks to Jeroen Akkerman in\n#3696, Fresh now checks whether\nthe page actually uses islands or partials (f-client-nav\n) before injecting\nanything. If it doesn’t, the page ships with no <script>\ntag, no module\npreload headers, and no client-side bundle at all.\nThere is nothing to configure. Static pages will just stop shipping JavaScript after upgrading.\nView Transitions\nThe View Transitions API lets browsers animate between DOM states natively. Fresh 2.3 wires it up to the existing partials system, so you can opt in by adding one attribute:\n<body f-client-nav f-view-transition>\n<!-- your app -->\n</body>\nPartial navigations will then be wrapped in document.startViewTransition()\nand\nyou can customize the animation with regular CSS:\n::view-transition-old(root) {\nanimation: fade-out 0.2s ease-in;\n}\n::view-transition-new(root) {\nanimation: fade-in 0.2s ease-out;\n}\nOr target individual elements:\n.sidebar {\nview-transition-name: sidebar;\n}\nBrowsers without support fall back to normal partial updates. See the View Transitions docs for more.\nFirst-class WebSocket support\nFresh now has built-in WebSocket support\n(#3774). The quickest way to add\na WebSocket endpoint is app.ws()\n:\nconst app = new App()\n.ws(\"/ws\", {\nopen(socket) {\nconsole.log(\"Client connected\");\n},\nmessage(socket, event) {\nsocket.send(`Echo: ${event.data}`);\n},\nclose(socket) {\nconsole.log(\"Client disconnected\");\n},\n});\nFor file-based routes, use ctx.upgrade()\ninside a GET handler. In managed\nmode, pass handlers and get the response back directly:\nexport const handlers = define.handlers({\nGET(ctx) {\nreturn ctx.upgrade({\nmessage(socket, event) {\nsocket.send(`Echo: ${event.data}`);\n},\n});\n},\n});\nThere’s also a bare mode. Call ctx.upgrade()\nwithout arguments to get the raw\nWebSocket\nobject, useful when you need to store sockets in a shared structure\nlike a chat room:\nconst clients = new Set<WebSocket>();\nexport const handlers = define.handlers({\nGET(ctx) {\nconst { socket, response } = ctx.upgrade();\nsocket.onopen = () => clients.add(socket);\nsocket.onmessage = (event) => {\nfor (const client of clients) {\nif (client.readyState === WebSocket.OPEN) {\nclient.send(event.data);\n}\n}\n};\nsocket.onclose = () => clients.delete(socket);\nreturn response;\n},\n});\nNon-WebSocket requests to a WebSocket route automatically get a 400 response.\nSee the\nWebSocket documentation for\nthe full API including idleTimeout\nand protocol\noptions.\nVite integration improvements\nA lot of this cycle went into making the Vite integration more robust, especially around npm package compatibility.\n- CJS-to-ESM transforms and\nprocess.env\nreplacements are now handled by Vite directly, so we could drop two Babel passes from the build. - CJS handling in SSR dev mode has been improved, React compat aliasing works\nproperly now, and\nresolve.alias\nis applied before Deno resolution. Packages like Radix UI work out of the box. optimizeDeps.exclude\nis set up so Vite no longer creates a duplicate Preact instance during pre-bundling.- Vite asset URLs now include a cache-bust query param so immutable caching does the right thing (#3761).\n- Temp files are ignored by the Vite watcher, so editor swap files no longer crash the dev server (#3763).\nWork in this area is continuing. #3767 removes the rest of the Babel transforms (~2,050 lines) and lets Vite handle CJS packages natively end to end. That should land shortly after 2.3.\nCSP nonces and IP filtering\nTwo new security middleware ship with this release.\nCSP nonce injection\n(#3709) generates a unique\nnonce\nper request and adds it to every inline <script>\nand <style>\ntag.\nThe corresponding Content-Security-Policy\nheader uses 'nonce-{value}'\ninstead of 'unsafe-inline'\n, so only scripts and styles that Fresh rendered are\nallowed to execute.\nimport { csp } from \"fresh\";\napp.use(csp({ useNonce: true }));\nUser-supplied CSP directives now override the defaults rather than duplicating them (#3724). See the CSP documentation for the full list of options.\nIP filter middleware (#3035, thanks to Octo8080X) adds built-in IP-based allow/deny lists with CIDR support:\nimport { ipFilter } from \"fresh\";\napp.use(ipFilter({\ndenyList: [\"192.168.1.10\"],\nallowList: [\"192.168.1.0/24\"],\n}));\nSee the IP filter documentation for custom response handling and more examples.\nOpenTelemetry: server-to-browser trace propagation\nFresh already instruments middleware, route handlers, and rendering with\nOpenTelemetry spans. In 2.3, it also injects a\nW3C traceparent\nmeta tag into the HTML\nresponse (#3729), so browser-side\ntelemetry SDKs can connect client spans to the server trace.\nEnable tracing with Deno’s built-in OpenTelemetry support:\nOTEL_DENO=true deno task start\nFresh will then automatically add the meta tag to every rendered page:\n<meta\nname=\"traceparent\"\ncontent=\"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01\"\n/>\nNo code changes required. See the OpenTelemetry documentation for exporter configuration and the full list of instrumented spans.\nTemporal API in islands\nThe Temporal API is landing in JavaScript engines, and Fresh now supports passing all eight Temporal types as island props:\nTemporal.Instant\nTemporal.ZonedDateTime\nTemporal.PlainDate\n,PlainTime\n,PlainDateTime\nTemporal.PlainYearMonth\n,PlainMonthDay\nTemporal.Duration\nYou can pass a Temporal value from a route straight through to an island:\nexport default function EventPage() {\nconst date = Temporal.PlainDate.from(\"2026-04-24\");\nreturn <Countdown target={date} />;\n}\nexport default function Countdown(props: { target: Temporal.PlainDate }) {\nconst today = Temporal.Now.plainDateISO();\nconst days = today.until(props.target).days;\nreturn <p>{days} days to go</p>;\n}\nMultiple static directories\nThe staticDir\noption now accepts an array\n(#3759). When the same filename\nexists in multiple directories, the first entry wins. This is useful when a\nbuild step generates assets into a separate directory and you want to keep those\nseparate from hand-authored files.\nimport { defineConfig } from \"vite\";\nimport { fresh } from \"@fresh/plugin-vite\";\nexport default defineConfig({\nplugins: [\nfresh({\nstaticDir: [\"static\", \"generated\"],\n}),\n],\n});\nSee the static files documentation for more.\nLoading indicators on form submissions\nLoading indicators used to only work for link clicks. In 2.3 they work for form submissions too (#3753). Fresh checks the submitter element first (for example, the clicked button) and falls back to the form, so you can have per-button indicators when a form has multiple submit buttons:\nimport { useSignal } from \"@preact/signals\";\nfunction MyForm() {\nconst saving = useSignal(false);\nreturn (\n<form action=\"/save\" f-partial=\"/partials/save\">\n<button\ntype=\"submit\"\nref={(el) => {\nif (el) el._freshIndicator = saving;\n}}\n>\n{saving.value ? \"Saving...\" : \"Save\"}\n</button>\n</form>\n);\n}\nReverse proxy support\nApps behind nginx, Caddy, or a cloud load balancer can now opt into\ntrustProxy\nso that ctx.url\nreflects the actual client-facing URL\n(#3757):\nconst app = new App({ trustProxy: true });\nWith this enabled, Fresh reads the X-Forwarded-Proto\nand X-Forwarded-Host\nheaders and rewrites ctx.url\naccordingly. If your proxy terminates TLS and\nforwards X-Forwarded-Proto: https\n, ctx.url.protocol\nwill be https:\ninstead\nof http:\n. See the\nreverse proxy documentation\nfor details.\ndeno create\nsupport\nWith Deno 2.7+, you can scaffold a new Fresh project using\ndeno create\n(#3706):\ndeno create @fresh/init\nThe old deno run -Ar jsr:@fresh/init\nform still works but now shows a\ndeprecation warning.\nBug fixes\nA non-exhaustive list of the most impactful fixes in this release:\nHttpError\nis now exposed for client-side code, with an optional message to keep bundle sizes down (#3080).- Routing: optional parameter routes no longer 404 (#2798), trailing slash mismatches no longer break static routes (#3721), middleware matches optional parameters in fs routing (#3726), and layouts apply correctly to index routes in programmatic routing (#3725).\n- Partials: forms without an explicit\nf-partial\ninsidef-client-nav\nare no longer intercepted (#3722), search params are preserved through redirects (#3715), and data script tags are appended to<head>\nduring partial navigation (#3720). - The\n<Head>\ncomponent now works correctly when rendered on the client (#3252). - Active links now consider query parameters and respect existing\naria-current\nattributes (#3755). - Better error messages for missing exports in file routes\n(#3718), warnings instead of\ncrashes on invalid HTML nesting around islands\n(#3762), and warnings for\nPartials with\nappend\n/prepend\nmode missing akey\nprop (#3738). - Pre-compiled middleware (#3104): middleware chains are now compiled once at startup instead of being assembled on every request.\n- Windows: paths are normalized in generated snapshot and server entry files (#3727).\nWhat’s next\nWe’re continuing to improve Vite support: upgrading to Vite 8 with Rolldown (#3760) and removing the remaining Babel transforms entirely (#3767).\nThe other big focus is build-time prerendering\n(#3766). Mark a route with\nprerender: true\nand Fresh renders it to static HTML during the build. Dynamic\nroutes can enumerate their paths too, effectively turning Fresh into a static\nsite generator for pages that don’t need a server.\nFollow along on GitHub.", "url": "https://wpnews.pro/news/fresh-2-3-zero-js-by-default-view-transitions-and-temporal-support", "canonical_source": "https://deno.com/blog/fresh-2.3", "published_at": "2026-04-24 15:00:00+00:00", "updated_at": "2026-05-22 12:18:20.452141+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "products"], "entities": ["Fresh", "Jeroen Akkerman", "Vite", "Deno"], "alternates": {"html": "https://wpnews.pro/news/fresh-2-3-zero-js-by-default-view-transitions-and-temporal-support", "markdown": "https://wpnews.pro/news/fresh-2-3-zero-js-by-default-view-transitions-and-temporal-support.md", "text": "https://wpnews.pro/news/fresh-2-3-zero-js-by-default-view-transitions-and-temporal-support.txt", "jsonld": "https://wpnews.pro/news/fresh-2-3-zero-js-by-default-view-transitions-and-temporal-support.jsonld"}}