Static SPAs: Exploration of Leptos, Dioxus, and Next.js

Christian Kjær in a casual setting :)
Christian Kjær
28 min read

·

5. July 2024

·

, , , , ,

One of my absolute favorite methods of deploying Frontends is to pre-generate all routes statically, and then let each route load the dependencies they need for interactivity.

  • Load times are fast
  • The user only downloads what they need (great for slower or mobile networks)
  • SEO is great in many cases, and okay in others1
  • It can all still be deployed in the most optimal way which is CDN + Static Assets2

I particularly care a lot about infrastructure scalability, maintainability, robustness, and cost. SSR typically makes each of these significantly worse compared to simply serving static files. I also don’t believe mixing your API into your Frontend code (i.e. Server Actions) will do you any favors when you start scaling up your teams, but that’s a topic for another day.

In this post we'll explore how this can be setup and compare a couple of different frameworks. Let's start with the overall results before we dive into the details:

FrameworkSSG SupportHydration SupportAssets Chunking
Next.js✅ Chunk per page
Leptos❌ One single WASM bundle*
Dioxus✅**❌ One single WASM bundle*

*: Support for chunking in WASM is generally poor, although recent strides have been made towards it (see wasm-bindgen#3939)

**: Dioxus support is in a state of flux, changing and improving how Static Generation is supported, leading to the docs and examples currently being broken (tracked in dioxus#2587)

In case it’s still not clear exactly what I mean with a “Static SPA”, let’s set up an example with Next.js, which supports this out of the box. The term for this is a bit messy, and you’ll find that React believes that this can still be called SSR where your rendering is just happening in compile time. Next.js will be calling this SSG in their Pages Router, and has confusingly changed the name of this in their newer App Router to be called Static Exports.

Let’s dive into the comparisons of how to achieve this across frameworks:

1

At least if that’s a concern and your App is not gated behind auth anyways, in which case the SEO aspect doesn’t matter at all

2

As opposed to SSR (Server-side Rendering) which requires a server to be running that can generate and serve your assets

Next.js SSG

Let’s set up a new Next.js project using the Next.js 15 release candidate so we can test all the latest advances for both React and Next.js.

We’re gonna accept most of the default recommendations:

$ bunx create-next-app@rc --turbo
 What is your project named? … next-example
 Would you like to use TypeScript? … Yes
 Would you like to use ESLint? … Yes
 Would you like to use Tailwind CSS? … Yes
 Would you like to use `src/` directory? … No
 Would you like to use App Router? (recommended)  Yes
 Would you like to use Turbopack for next dev? … Yes
 Would you like to customize the default import alias (@/*)? … No
Creating a new Next.js app in ./next-example.

If we cd next-example and run bun run dev we’ll get a nice little start page:

The default Next.js start page

Extending the default Next.js project

Now, for us to actually see anything interesting being generated later on, we’ll need to have more pages than just a single start page. Let’s add a few new pages with some test content.

We’ll set up two pages, reusing the styling of the main page, the first one in next-example/app/sub-page/page.tsx (create the missing sub-page directory):

1"use client";
2
3import { useState } from "react";
4
5export default function SubPage() {
6 const [counter, setCounter] = useState(0);
7 return (
8 // Reused styling.
9 <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
10 <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
11 {/* Our content */}
12 <h1>SubPage</h1>
13 <p>This is a subpage with interactivity: {counter}</p>
14 <button onClick={() => setCounter((prev) => prev + 1)}>
15 Increment
16 </button>
17 </main>
18 </div>
19 );
20}

And another similar one in next-example/app/another-page/page.tsx (create the missing another-page directory):

1"use client";
2
3import { useState, useEffect } from "react";
4
5export default function AnotherPage() {
6 const [windowHeight, setWindowHeight] = useState<number | undefined>(
7 undefined
8 );
9 // Ensure that the window object is available before calling browser APIs.
10 useEffect(() => {
11 setWindowHeight(window.innerHeight);
12 }, []);
13
14 return (
15 // Reused styling.
16 <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
17 <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
18 {/* Our content */}
19 <h1>Another Page</h1>
20 <p>
21 This is a another page calling browser APIs
22 {windowHeight ? `: ${windowHeight}` : ""}
23 </p>
24 </main>
25 </div>
26 );
27}

Note that in both of these examples we’re doing things that happen on the client-side (via useState and useEffect), so we need the "use client"; pragma at the top.

And finally, we’ll link to these pages from our App entry point in next-example/app/page.tsx:

1import Image from "next/image";
2import Link from "next/link"; // This line here
3
4export default function Home() {
5 return (
6 <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
7 <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
8 <Image
9 className="dark:invert"
10 src="/next.svg"
11 alt="Next.js logo"
12 width={180}
13 height={38}
14 priority
15 />
16 <Link href="/sub-page">Sub page</Link> {/* <-- This line here */}
17 <Link href="/another-page">Another page</Link> {/* <-- This line here */}
18 {/* ...keep the rest of the content */}

We can run the development server to check that everything works via bun run dev in the next-example directory:

Our updated start page
The sub-page with an increment button
The another-page showing the browser height

Now that we have a few pages to render, using some interactivity and browser APIs, let’s take a look at how we’ll do that.

Configuring SSG

The first change we’ll make will be to change our build output to be an export which is what Next.js calls SSG in their App Router.

Open your next-example/next.config.mjs file and add the following lines:

1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 output: "export", // <-- This line here
4 // Generate index.html files for each route in the output.
5 trailingSlash: true, // <-- This line here
6};
7
8export default nextConfig;

The trailingSlash configuration will depend on your deployment method, but it essentially makes sure our simple static file server later on can serve the files without needing any logic to rewrite paths.

We can now inspect the generated build assets by running bun run build in the next-example directory

$ bun run build
...
Route (app)                              Size     First Load JS
 ○ /                                    13.3 kB         102 kB
 ○ /_not-found                          900 B          89.8 kB
 ○ /another-page                        882 B          89.8 kB
 ○ /sub-page                            872 B          89.8 kB
+ First Load JS shared by all            88.9 kB
   chunks/180-42348583fa4569ac.js       35.6 kB
   chunks/4bd1b696-a08a63850fcad1d6.js  51.4 kB
   other shared chunks (total)          1.86 kB

Let’s check out what Next.js generated for us in the out/ folder:

out
├── 404
   └── index.html
├── 404.html
├── _next
   ├── K46Mb3V6rvnSZkb3S0DFD
   └── static
       ├── K46Mb3V6rvnSZkb3S0DFD
       │   ├── _buildManifest.js
       │   └── _ssgManifest.js
       ├── chunks
       │   ├── 182-5aa8ba6aa9ca46c7.js
       │   ├── 4bd1b696-3f85179bbee9de79.js
       │   ├── 932-e409b1b1d42740a9.js
       │   ├── app
       │   │   ├── _not-found
       │   │   │   └── page-1477ca64449fa6ca.js
       │   │   ├── another-page
       │   │   │   └── page-aa4b7b15eb983969.js
       │   │   ├── layout-94b4bb4f7b4ed0fc.js
       │   │   ├── page-18c8594afae77168.js
       │   │   └── sub-page
       │   │       └── page-b5ac62c0a67a677a.js
       │   ├── framework-d2f4bc65ced8d4a1.js
       │   ├── main-286537da132b2fda.js
       │   ├── main-app-5fa6097c9a9b5d34.js
       │   ├── pages
       │   │   ├── _app-daa5cb8560567b4d.js
       │   │   └── _error-4cacf33de97c3163.js
       │   ├── polyfills-78c92fac7aa8fdd8.js
       │   └── webpack-d22356f1f4db00c4.js
       ├── css
       │   └── ba86f67683758cf0.css
       └── media
           ├── 4473ecc91f70f139-s.p.woff
           └── 463dafcda517f24f-s.p.woff
├── another-page
   ├── index.html
   └── index.txt
├── favicon.ico
├── file-text.svg
├── globe.svg
├── index.html
├── index.txt
├── next.svg
├── sub-page
   ├── index.html
   └── index.txt
├── vercel.svg
└── window.svg

The interesting files here are:

  • out/index.html: The generated HTML for our starting page
  • out/sub-page/index.html: The generated HTML for our sub-page
  • out/_next/static/chunks/app/sub-page/page-b5ac62c0a67a677a.js: The JavaScript that is specific to our sub-page, and will only be loaded on that page
  • out/another-page/index.html: The generated HTML for our another-page
  • out/_next/static/chunks/app/another-page/page-aa4b7b15eb983969.js: The JavaScript that is specific to our another-page

The chunking of the JavaScript into smaller files, some shared and some page specific, is great for our users and will mean they’ll only load the minimal JavaScript that they need to interact with the page they are on.

Testing with JavaScript disabled

We can test our site in Chrome without JavaScript by opening the Chrome DevTools, hitting CMD + Shift + P, and typing in “Disable JavaScript”

Disable JavaScript via the Chrome DevTools

We can then serve our static files by via cd next-example/out && bunx simplehttpserver. Let’s make some comparisons, running with JavaScript and with JavaScript disabled.

First, our start page. Looking exactly the same, with all links working and no other changes in functionality since we didn’t have anything interactive here:

Start page with JavaScript enabled
Start page with JavaScript disabled

Our sub-page will look exactly the same, but once you try to interact with the Increment button when JavaScript is disabled it obviously will not work:

sub-page with JavaScript enabled
sub-page with JavaScript disabled

We do notice a tiny difference in our another, specifically it will never call the useEffect to get the browser height when JavaScript is disabled. Nothing surprising here:

another-page with JavaScript enabled
another-page with JavaScript disabled

The above should demonstrate exactly the benefit of this approach: We get most of the benefits of SSR without paying the costs. SEO crawlers will be able to read most of our content, except for the dynamic parts.

There’s a lot more you can do in the static exports, e.g. generate dynamic routes such as pre-generating all your i18n routes for each language.

I have an example of that in the Next.js setup I use in this post: https://codethoughts.io/posts/2023-10-16-the-stack-part-3/#next-js.

Optional configuration

If you’re looking for some additional recommendations to make your Next.js site better for your users, I’d recommend the following.

Enable the experimental support for the React Compiler, by first installing it:

$ bun install --dev babel-plugin-react-compiler

And updating the next-example/next.config.mjs:

1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 output: "export",
4 // Generate index.html files for each route in the output.
5 trailingSlash: true,
6 // Optional: Enable experimental React Compiler support.
7 experimental: {
8 reactCompiler: true, // <-- This line here
9 },
10};
11
12export default nextConfig;
13

Further Reading

It’s worth reading the notes on supported features as well as unsupported features. Here are a few snippets:

Server Components: When you run next build to generate a static export, Server Components consumed inside the app directory will run during the build, similar to traditional static-site generation.

and:

Client Components: If you want to perform data fetching on the client, you can use a Client Component with SWR to memoize requests.

as well as:

Browser APIs: Client Components are pre-rendered to HTML during next build. Because Web APIs like windowlocalStorage, and navigator are not available on the server, you need to safely access these APIs only when running in the browser.

Leptos SSG

Now that we have a clear concrete idea of what our goal is and why we are trying to achieve it, let’s see if we can make a similar setup using Leptos and WASM. We unfortunately can’t test the new 0.7 alpha since static routes are yet to be supported.

We do however need to be using a branch, which includes my fix for generating sub-routes with trailing slashes correctly (leptos#2667), until that gets merged. I'd recommend just waiting until it's merged and a new release is cut.

If you don’t already have cargo-leptos and cargo-generate, we’ll set that up first so that we can use the leptos-rs/start-axum starter template:

$ cargo binstall cargo-generate --yes # Or: cargo install cargo-generate
$ cargo binstall cargo-leptos # Or: cargo install cargo-leptos --locked

We can now get started with our Leptos project, calling it leptos-example:

$ cargo leptos new --git leptos-rs/start-axum
🤷   Project Name: leptos-example
🤷   Use nightly features? · Yes

Let’s cd leptos-example and finish setting up the project by adding our compiler targets using rustup:

$ rustup toolchain install nightly --allow-downgrade
$ rustup target add wasm32-unknown-unknown

And finally, we’ll want the generated files to be hashed. Edit your leptos-example/Cargo.toml:

1# ...add to your existing [package.metadata.leptos]
2[package.metadata.leptos]
3# Enables additional file hashes on outputted css, js, and wasm files
4#
5# Optional: Defaults to false. Can also be set with the LEPTOS_HASH_FILES=false env var (must be set at runtime too)
6hash-files = true

If we run cd leptos-example and run cargo leptos watch we get a nice little start page:

The default Leptos start page using the start-axum template

To avoid your eyes bleeding from the default white background, you can update the default leptos-example/style/main.scss to:

1body {
2 font-family: sans-serif;
3 text-align: center;
4 background: #0a0a0a;
5 color: #ededed;
6}

Extending the default Leptos project

Just like with Next.js, for us to actually see anything interesting being generated later on, we’ll need to have more pages than just a single start page. Let’s add a few new pages with some test content.

We’ll set up two pages, the first one in leptos-example/src/subpage.rs, mimicking the equivalent Next.js page:

1use leptos::*;
2
3#[component]
4pub fn SubPage() -> impl IntoView {
5 // Creates a reactive value to update the button
6 let (count, set_count) = create_signal(0);
7 let on_click = move |_| set_count.update(|count| *count += 1);
8
9 view! {
10 <h1>"SubPage"</h1>
11 <p>"This is a subpage with interactivity: " {count}</p>
12 <button on:click=on_click>"Increment"</button>
13 }
14}

Similarly, we’ll set up in leptos-example/src/anotherpage.rs, mimicking the equivalent Next.js page:

1use leptos::*;
2use leptos_dom::window;
3
4#[component]
5pub fn AnotherPage() -> impl IntoView {
6 let (window_height, set_window_height) = create_signal::<Option<f64>>(None);
7 let window_text = move || {
8 window_height()
9 .map(|w| format!(": {}", w))
10 .unwrap_or("".to_owned())
11 };
12
13 // Ensure that the window object is available before calling browser APIs.
14 create_effect(move |_| {
15 set_window_height(window().inner_height().ok().map(|w| w.as_f64().unwrap()));
16 });
17
18 view! {
19 <h1>"Another Page"</h1>
20 <p>"This is a another page calling browser APIs" {window_text}</p>
21 }
22}

We’ll also need to update our leptos-example/src/lib.rs to make these pages accessible in our project:

1pub mod app;
2pub mod error_template;
3#[cfg(feature = "ssr")]
4pub mod fileserv;
5pub mod subpage; // This line here
6pub mod anotherpage; // This line here
7
8// ...the rest of the file remains the same

And finally, add these pages to our Routes with a link by editing leptos-example/src/app.rs by first importing them:

1use crate::error_template::{AppError, ErrorTemplate};
2use leptos::*;
3use leptos_meta::*;
4use leptos_router::*;
5use crate::subpage::SubPage; // This line here
6use crate::anotherpage::AnotherPage; // This line here
7// ...

And then extending our Routes in the App component:

1 // ...add the routes in our App component.
2 <Routes>
3 <Route path="" view=HomePage/>
4 <Route path="/sub-page" view=SubPage/> // This line here
5 <Route path="/another-page" view=AnotherPage/> // This line here
6 </Routes>
7 // ...

And finally, extend our HomePage component, adding the links:

1#[component]
2fn HomePage() -> impl IntoView {
3 // Creates a reactive value to update the button
4 let (count, set_count) = create_signal(0);
5 let on_click = move |_| set_count.update(|count| *count += 1);
6
7 view! {
8 <h1>"Welcome to Leptos!"</h1>
9 <button on:click=on_click>"Click Me: " {count}</button>
10 <A href="/sub-page">"Sub page"</A> // This line here
11 <A href="/another-page">"Another page"</A> // This line here
12 }
13}

We can run the development server to check that everything works via cargo leptos watch in the leptos-example directory:

Our updated start page
The sub-page with an increment button
The another-page showing the browser height

Now that we have a few pages to render, using some interactivity and browser APIs, let’s take a look at how we’ll do that.

Configuring SSG

While static generation is a bit underdocumented in Leptos, we can get still manage by following along with Greg’s comment here as well as the 0.5 release notes on where Static Site Generation was announced.

Before making any changes, until leptos#2667 is merged, we'll have to change our leptos dependency to point to our PR:

1[package]
2# ...
3
4[dependencies]
5# ...
6leptos = { git = "https://github.com/leptos-rs/leptos", rev = "refs/pull/2667/head", features = ["nightly"] }
7leptos_axum = { git = "https://github.com/leptos-rs/leptos", rev = "refs/pull/2667/head", optional = true }
8leptos_meta = { git = "https://github.com/leptos-rs/leptos", rev = "refs/pull/2667/head", features = ["nightly"] }
9leptos_router = { git = "https://github.com/leptos-rs/leptos", rev = "refs/pull/2667/head", features = ["nightly"] }
10# ...the rest of the file remains the same

Now we're ready for the actual changes. First thing, we’ll do is to change our Routes to be StaticRoutes instead. Edit the leptos-example/src/app.rs file, changing our Routes to look like this instead:

1 // ...change the routes in our App component.
2 <Routes>
3 <StaticRoute
4 mode=StaticMode::Upfront
5 path=""
6 view=HomePage
7 static_params=move || Box::pin(async move { StaticParamsMap::default() })
8 /> // This line here
9 <StaticRoute
10 mode=StaticMode::Upfront
11 path="/sub-page/"
12 trailing_slash=TrailingSlash::Exact
13 view=SubPage
14 static_params=move || Box::pin(async move { StaticParamsMap::default() })
15 /> // This line here
16 <StaticRoute
17 mode=StaticMode::Upfront
18 path="/another-page/"
19 trailing_slash=TrailingSlash::Exact
20 view=AnotherPage
21 static_params=move || Box::pin(async move { StaticParamsMap::default() })
22 /> // This line here
23 </Routes>
24 // ...

Both the trailing_slash=TrailingSlash::Exact and the / on the routes are important for Leptos to build the files in the correct location and way we expect.

We also need to tell Leptos to generate the output for these files, which we’ll do by updating our leptos-example/src/main.rs with some additional imports so that we can call build_static_routes:

1#[cfg(feature = "ssr")]
2#[tokio::main]
3async fn main() {
4 // ...update our imports with generate_route_list_with_ssg and build_static_routes
5 use leptos_axum::{generate_route_list_with_ssg, build_static_routes, LeptosRoutes};
6 // ...replace the existing let routes ... with this
7 let (routes, static_data_map) = generate_route_list_with_ssg(App); // This line here
8 build_static_routes(&leptos_options, App, &routes, static_data_map).await; // This line here
9 // ...the rest of the file remains the same
10}

This will generate the static files each time our entry function runs.

We can now inspect the generated build assets by running LEPTOS_HASH_FILES=true cargo leptos build --release following by running the binary via LEPTOS_HASH_FILES=true ./target/release/leptos-example in the leptos-example directory (you might have to exit it manually with Ctrl-C):

$ LEPTOS_HASH_FILES=true cargo leptos build --release
$ LEPTOS_HASH_FILES=true ./target/release/leptos-example
building static route: /sub-page
listening on http://127.0.0.1:3000
building static route: /another-page
building static route:
# ..exit the process after you've seen it build the routes

Let’s check out what Next.js generated for us in the target/site/ folder:

$ tree target/site
target/site
├── another-page
   └── index.html
├── favicon.ico
├── index.html
├── pkg
   ├── leptos-example.8RfUbHBiJMoruMHObx2F7Q.css
   ├── leptos-example.XcFzle8Cx3F6iOeNDhvIAw.js
   └── leptos-example.p9VEDheNIWNS98tonwfhvw.wasm
└── sub-page
    └── index.html

Testing with JavaScript disabled

Same approach as last, we’ll test our site in Chrome without JavaScript by opening the Chrome DevTools, hitting CMD + Shift + P, and typing in “Disable JavaScript”

Disable JavaScript via the Chrome DevTools

We can then serve our static files by via cd leptos-example/target/site && bunx simplehttpserver. Let’s make some comparisons, running with JavaScript and with JavaScript disabled.

First, our start page. Looking exactly the same, with all links working and no other changes in functionality since we didn’t have anything interactive here:

Start page with JavaScript enabled
Start page with JavaScript disabled

Our sub-page will look exactly the same, but once you try to interact with the Increment button when JavaScript is disabled it obviously will not work:

sub-page with JavaScript enabled
sub-page with JavaScript disabled

We do notice a tiny difference in our another, specifically it will never call the create_effect to get the browser height when JavaScript is disabled. Nothing surprising here:

another-page with JavaScript enabled
another-page with JavaScript disabled

Very similar results to Next.js!

Dioxus SSG

Let’s explore another Rust-based framework, Dioxus, to see how SSG is supported and how well it works.

We’ll start by setting up the Dioxus CLI:

# $ cargo binstall dioxus-cli # Or: cargo install dioxus-cli
$ cargo install --git https://github.com/DioxusLabs/dioxus dioxus-cli

We can then use the dx CLI to create our new project, which we’ll set up as a fullstack project which can “renders to HTML text on the server and hydrates it on the client”3:

$ dx new
 🤷   Which sub-template should be expanded? · Fullstack
  🤷   Project Name: dioxus-example
 🤷   Should the application use the Dioxus router? · true
 🤷   How do you want to create CSS? · Tailwind

If you chose TailwindCSS, you won’t have any default styling. Let’s make a small improvement to dioxus-example/input.css, which is where the Tailwind styles will be picked up from. That’ll look like this:

1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5body {
6 @apply bg-black text-white text-center justify-center mt-16;
7 a {
8 @apply underline mx-4;
9 }
10 button {
11 @apply bg-neutral-700 p-2 m-4;
12 }
13}

And then run bunx tailwindcss -i ./input.css -o ./assets/tailwind.css in the dioxus-example directory to get the styles compiled into dioxus-example/assets/tailwind.css.

You can also safely delete the alternative dioxus-example/assets/main.css which would have been used if we hadn’t chosen TailwindCSS.

Finally, we’ll add the web-sys package to our dependencies in dioxus-example/Cargo.toml, which we’ll need later:

1[package]
2# ...
3
4[dependencies]
5# Add web-sys crate to interact with Browser APIs.
6web-sys = { version = "0.3", features = ["Window"] }
7# ...the rest of the file remains the same

If we now cd dioxus-example and run dx serve --platform fullstack we get a nice little start page:

The default Dioxus start page using the fullstack template.

The default Dioxus start page using the fullstack template.

Extending the default Dioxus project

Once again, for us to actually see anything interesting being generated later on, we’ll need to have more pages than just a single start page4. Let’s add a few new pages with some test content.

We’ll set up two pages, the first one in dioxus-example/src/subpage.rs, mimicking the equivalent Next.js page:

1use dioxus::prelude::*;
2
3#[component]
4pub fn SubPage() -> Element {
5 // Creates a reactive value to update the button
6 let mut count = use_signal(|| 0);
7
8 rsx! {
9 h1 { "SubPage" }
10 p { "This is a subpage with interactivity: {count}" }
11 button { onclick: move |_| count += 1, "Increment" }
12 }
13}

Similarly, we’ll set up in dioxus-example/src/anotherpage.rs, mimicking the equivalent Next.js page:

1use dioxus::prelude::*;
2use web_sys::window;
3
4#[component]
5pub fn AnotherPage() -> Element {
6 let mut window_height = use_signal::<Option<f64>>(|| None);
7 let window_text = use_memo(move || {
8 window_height()
9 .map(|w| format!(": {}", w))
10 .unwrap_or("".to_owned())
11 });
12
13 // Ensure that the window object is available before calling browser APIs.
14 use_effect(move || {
15 let window = window();
16 if let Some(w) = window {
17 window_height.set(w.inner_height().ok().map(|w| w.as_f64().unwrap()));
18 }
19 });
20 rsx! {
21 h1 { "Another Page" }
22 p { "This is a another page calling browser APIs{window_text}" }
23 }
24}

We can now integrate these pages into our App in dioxus-example/src/main.rs. First, we’ll change our Route enum to have our new pages, and remove the default blog page:

1// ...
2#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
3enum Route {
4 #[route("/")]
5 Home {},
6 #[route("/sub-page")] // This line here
7 SubPage {}, // This line here
8 #[route("/another-page")] // This line here
9 AnotherPage {}, // This line here
10}
11// ...

Then we’ll update our Home component to link to the pages, and remove the link to the blog page as well as the Blog component:

1#[component]
2fn Home() -> Element {
3 let mut count = use_signal(|| 0);
4 let mut text = use_signal(|| String::from("..."));
5
6 rsx! {
7 Link { to: Route::SubPage {}, "Sub page" } // This line here
8 Link { to: Route::AnotherPage {}, "Another page" } // This line here
9 div {
10 h1 { "High-Five counter: {count}" }
11 button { onclick: move |_| count += 1, "Up high!" }
12 button { onclick: move |_| count -= 1, "Down low!" }
13 button {
14 onclick: move |_| async move {
15 if let Ok(data) = get_server_data().await {
16 tracing::info!("Client received: {}", data);
17 text.set(data.clone());
18 post_server_data(data).await.unwrap();
19 }
20 },
21 "Get Server Data"
22 }
23 p { "Server data: {text}"}
24 }
25 }
26}

We can run the development server to check that everything works via dx serve --platform fullstack in the dioxus-example directory:

Our updated start page
The sub-page with an increment button
The another-page showing the browser height

Now that we have a few pages to render, using some interactivity and browser APIs, let’s take a look at how we’ll do that.

4

Dioxus does add a sub-route by default, but we’ll ignore that to make the comparisons equal

Configuring SSG

Until there’s a new release of Dioxus, we’ll have to install the CLI from git, to get the latest features:

cargo install --git https://github.com/DioxusLabs/dioxus dioxus-cli

We’ll also update our dependencies to point dioxus to a working commit where static site generation is included in 245003a5d430ab8e368094cd32208178183fc24e in dioxus-example/Cargo.toml, as well as replace the fullstack feature with the static-generation feature:

1[package]
2# ...
3
4[dependencies]
5# Update dixous to use this specific commit 245003a5d430ab8e368094cd32208178183fc24e
6dioxus = { git = "https://github.com/DioxusLabs/dioxus", rev = "245003a5d430ab8e368094cd32208178183fc24e", features = [
7 "static-generation",
8 "router",
9] }
10# ...the rest of the file remains the same

We can now replace both the default main functions with a very simple one, as well as delete the default post_server_data and get_server_data functions. Your dioxus-example/src/main.rs should now look like this:

1#![allow(non_snake_case)]
2
3use dioxus::prelude::*;
4
5mod subpage;
6use subpage::SubPage;
7mod anotherpage;
8use anotherpage::AnotherPage;
9
10#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
11enum Route {
12 #[route("/")]
13 Home {},
14 #[route("/sub-page")]
15 SubPage {},
16 #[route("/another-page")]
17 AnotherPage {},
18}
19
20// Generate all routes and output them to the static path
21fn main() {
22 launch(App);
23}
24
25fn App() -> Element {
26 rsx! {
27 Router::<Route> {}
28 }
29}
30
31#[component]
32fn Home() -> Element {
33 let mut count = use_signal(|| 0);
34
35 rsx! {
36 Link { to: Route::SubPage {}, "Sub page" }
37 Link { to: Route::AnotherPage {}, "Another page" }
38 div {
39 h1 { "High-Five counter: {count}" }
40 button { onclick: move |_| count += 1, "Up high!" }
41 button { onclick: move |_| count -= 1, "Down low!" }
42 }
43 }
44}

Dioxus knows which launch function to call, based on the feature flags enabled, which in our case is static-generation.

Finally, we’ll need to adjust the default path to our generated tailwind.css file to make sure that sub-routes load it correctly from the root and not relative to themselves. Adjust the style setting in dioxus-example/Dioxus.toml:

1# ...
2[web.resource]
3style = ["/tailwind.css"]
4# ...the rest of the file remains the same

We can now inspect the generated build assets by running dx build --platform fullstack --release following by running the binary via ./dist/dioxus-example in the dioxus-example directory:

1$ dx build --platform fullstack --release
2...
3build desktop done
4$ ./dist/dioxus-example

Let’s check out what Next.js generated for us in the static/ folder (not the dist/ folder!):

$ tree static
static
├── __assets_head.html
├── another-page
   └── index.html
├── assets
   └── dioxus
       ├── dioxus-example.js
       ├── dioxus-example_bg.wasm
       └── snippets
           ├── dioxus-interpreter-js-7c1300c6684e1811
           │   ├── inline0.js
           │   └── src
           │       └── js
           │           └── common.js
           ├── dioxus-interpreter-js-9ac3b5e174d5b843
           │   ├── inline0.js
           │   └── src
           │       └── js
           │           ├── common.js
           │           └── eval.js
           ├── dioxus-web-84af743b887ebc54
           │   ├── inline0.js
           │   ├── inline1.js
           │   └── src
           │       └── eval.js
           └── dioxus-web-90b865b1369c74f4
               ├── inline0.js
               └── inline1.js
├── dioxus-example
├── favicon.ico
├── header.svg
├── index.html
├── sub-page
   └── index.html
└── tailwind.css

Testing with JavaScript disabled

Same approach as last, we’ll test our site in Chrome without JavaScript by opening the Chrome DevTools, hitting CMD + Shift + P, and typing in “Disable JavaScript”

Disable JavaScript via the Chrome DevTools

We can then serve our static files by via cd dioxus-example/static && bunx simplehttpserver. Let’s make some comparisons, running with JavaScript and with JavaScript disabled.

First, our start page. Looking exactly the same, with all links working and no other changes in functionality since we didn’t have anything interactive here:

Start page with JavaScript enabled
Start page with JavaScript disabled

Our sub-page will look exactly the same, but once you try to interact with the Increment button when JavaScript is disabled it obviously will not work:

sub-page with JavaScript enabled
sub-page with JavaScript disabled

We do notice a tiny difference in our another, specifically it will never call the use_effect to get the browser height when JavaScript is disabled. Nothing surprising here:

another-page with JavaScript enabled
another-page with JavaScript disabled

Conclusion

While Next.js certainly had the smoothest path and the best support for SSG, especially with the benefit of asset chunking, the Rust-based frameworks show a viable path forward if you are committed to staying in the Rust ecosystem.

I personally found Leptos easier to set up and work with, but either framework is progressing very fast, so it’s a bit more up to personal preference.

As mentioned in the beginning of the post, our end results ended up being:

FrameworkSSG SupportHydration SupportAssets Chunking
Next.js✅ Chunk per page
Leptos❌ One single WASM bundle*
Dioxus✅**❌ One single WASM bundle*

*: Support for chunking in WASM is generally poor, although recent strides have been made towards it (see wasm-bindgen#3939)

**: Dioxus support is in a state of flux, changing and improving how Static Generation is supported, leading to the docs and examples currently being broken (tracked in dioxus#2587)

👉 Let me know what you think over on Medium or in the comments below 👇