Rust and WASM for form validation
In recent years, Rust and WebAssembly have become much more usable for pure backend-style engineers. When I say “pure backend-style”, I mean people who never wrapped their heads around React, SPAs, and all that stuff. This, unsurprisingly, includes me.
For a very long time, in order to use WASM you were strongly guided towards using Webpack and a whole array of Node-related tools in order to just get started. These days, luckily, the story has become much more streamlined. In today’s tutorial, we’re going to create a quick project which includes both a web server in Rust which renders HTML templates, and a small WASM component (served by the same web server) that does form validation.
Why?
Well, I want to keep a reference handy about how this can be done. But I also want to hear if I’m doing something wrong or suboptimally.
As to why Rust/WASM instead of standard JS or TS? For one, I like to see how far we’ve come with WASM, and there is something very appealing about having the same code shared across the frontend and the backend, especially if it means potentially having the same data structures when serialising/deserialising data from one end to the other.
I’m using form validation as a placeholder. It shows all the crucial aspects to use WASM instead of JS, like wiring up DOM events to Rust functions, and then reacting to those events.
Stack
This post is being written in early-to-mid 2025. And the stack I currently jive with is as follows:
- wasm-bindgen 0.2
- wasm-pack 0.13
- web-sys 0.3
- rocket 0.5
I’m not going to do any database-related things or anything, which allows us to keep the dependencies pretty short and sweet. Most of these are library dependencies, however wasm-pack
is a binary that actually modifies files and prepares them for distribution. This needs to be installed locally:
# binstall can also be used here
Project structure
Let’s create a directory structure that allows this whole setup to work. One typical approach for this is to have separate crates for the server part, and the actual wasm component. Something like this:
tutorial
├── server
└── wasm
If you want to create things quickly:
&&
If you prefer to see the entire repo instead of creating all the files yourself, there is full repository available here.
WASM setup
Installing wasm as a target
One thing that you might have to do is to get the compiler support to actually produce wasm binaries. Provided you use rustup (and you really ought to be using it), this is as simple as:
You can check which targets you currently have installed with rustup target list --installed
.
Configuring the wasm crate
Edit Cargo.toml
and add the following section. This tells the compiler to produce a dynamic library that has a relatively stable ABI. This is similar to building a library for C/C++ interop.
[]
= ["cdylib"]
Let’s also pull in some dependencies while we’re editing that file:
[]
= "0.2"
[]
= "0.3"
= [
"Document",
"HtmlFormElement",
"HtmlInputElement",
"Window",
"console",
"Event",
]
At this point, you should be able to build the library in wasm form using the following command:
This will produce a few files in pkg/
:
Great, now that we’re producing a WASM library that can be loaded from a browser, it’s time to build the server.
Server side implementation
Let’s add a few libraries to our server’s Cargo.toml
:
[]
= "0.5"
[]
= "0.2"
= ["tera"]
Rocket is our main server framework. It provides both sync and async support, has a strong database integration, but also is easy enough to use that it won’t get in the way. In our src/main.rs
, let’s add a some code to handle our fictional login. Anyone with the password “hunter2” will be allowed in.
If you’ve worked with things like FastAPI the above most likely doesn’t shock you too much. We define a verify_password
function that validates anyone who knows the secret handshake, and we render a form where users can submit their credentials when they access /login
.
We then define the shape of the form data that we expect (&'a str
is effectively a pointer to a string, if you come from Golang or the Cs) in our LoginInformation struct, and we define a handler when people submit a POST request to the same path /login
. Rocket handles the form parsing for us, and will balk at the client if they submit unexpected data. When our function is called, we know the data is at least UTF-8 which should be reasonably safe to process.
Note: handle_login
could be declared as an async function (async fn handle_login(...)
), and absolutely nothing else in our code would change. Rocket automatically handles the future that an async function produces.
We need a tiny bit more glue to start the rocket server, so let’s replace our main function with something that actually starts the server:
There’s some cool stuff happening here. We tell Rocket that we want the /login
handlers to be mounted on /
, and we start a file server handler on /wasm
, and we tell it to serve the files created by our wasm library. Finally, we give Rocket a way to actually render our templates.
We do need to create a couple of templates for Rocket to render our pages (both the login form and the success page). I’m keeping things very simple and therefore ugly. I would advise young children to close their eyes. Here is our beautiful login.html.tera
:
Login
Email
Password
And now for our success.html.tera
:
Login successful
Welcome {{ username }}
You've successfully logged in!
Shove both of these templates in a directory called templates
in your server
project, and Rocket should be able to start when you launch it:
>> ()
>> ()
>> ()
>> directory:
>> engines:
Note: I’ve skipped a few lines of output from Rocket for brevity.
What does this tell us? For one, Rocket properly picked up our route handlers and allows us to load the wasm, the template engine is registered. With the server running, you should be able to access the login form.
And with that, our server is done!
Adding more features to the WASM
Now, form validation is pretty boring in and of itself; I just decided on that concept as a way to demonstrate the usefulness of Rust. The first thing I’d like to do is make the WASM print something in the console to show it did indeed correctly load. I think it’s quite poor practice to just randomly leave messages in the browser console.log for no reason, but for this tutorial I think it’s fine.
In our wasm/src/lib.rs
, let’s replace the existing code with the following snippet:
After rebuilding the wasm library and ensuring the server is started, we can see the nice greeting in our browser’s console. Success!
Now, our HTML already does a lot of heavy lifting when it comes to form validation. If you just try to login with “foo” as the email, your browser should prevent you from submitting the form and ask for a valid email address. In my browser, the requirement is barely more than having an ‘@’ in the value (so for example my browser will happilly accept .@e
as a valid email address).
So what can we do to improve it?
Well, modern browsers allow you to piggyback on top of their internal form validation. You don’t need to add a whole bunch of extra HTML to display error messages and stuff. You just need to tell the browser what to do.
The first thing in that process is that we need to start doing some good ol’ getElementById stuff, but oxidised. We need a handle on the form to be able to cancel its default behaviour, and submit it later on once validation is complete.
let doc = window.unwrap.document.unwrap;
let form = doc
.get_element_by_id
.unwrap
.
.unwrap;
let form_ref = form.clone;
The form_ref
is something we need because HtmlFormElement
is a refcounted object that can’t be copied willy-nilly. And in the next section, we’ll move form
into a different context, but we still need to keep a handle on it later.
Next, we need to define the function that will be called by the onsubmit
event.
let closure = new;
Okay, there’s quite a bit going on here. Let’s break it down. The first line creates a new Closure (a function that can be called JS/DOM). This closure must be 'static
, which is why we have that move
keyword in there. Any outside variable we reference will be moved into it.
Next, we do another get_element_by_id
to find our email input field, nothing surprising there. We then use the constraints validation API to, again, do the heavy lifting for us. We start by checking whether the native checks pass, and report the error/refuse to submit if it didn’t.
The next chunk is bit more interesting, but still very basic. We know that the email at least contains a ‘@’ because the native checks have passed, so we can just split the string on that. We can then check if the user comes from the correct domain. If there is no domain to speak of (the None
case), or if the domain is not example.com
, then we bail and show an error message.
The slightly awkward call to set_custom_validity
with an empty string is sadly how this browser API is expected to work.
As a last step in this closure, if everything validated fine, we can submit the form.
The final piece of the puzzle is to attach the closure to the form’s event listener. This can be achieved with:
form.add_event_listener_with_callback?;
closure.forget;
This is why we had to create that form_ref
value that was used in the closure. We need both the closure and the form, but the closure uses the form to submit, and then we need the form to attach the closure. It’s a chicken and egg problem, so we get around it by cloning the egg. Or maybe we cloned the chicken?
Closing thoughts
I think that WASM story in Rust is really strong now. With just a handful lines of code we can get access to an incredibly powerful language embedded in our otherwise extremely simple web page. WASM is still relatively heavy1, compared to the equivalent JS that would’ve accomplished the same (20KB or so vs a few hundred bytes), but this is also an extremely contrived example case. We’re paying a heavy penalty to get started with WASM, because we are using a few libraries that hide a lot of the ugly details from us. If we were to add a few hundred more validation functions, the code size would barely increase, whereas it would increase linearly with JS, so there are economies of scale to be had.
Obviously, the sample code above unwrap
s to high heaven, and that’s nothing something I would condone in actual production code—please do use proper error handling.
That about wraps up our exercise for today. We’ve built a simple web app that uses Rust for both backend and frontend logic, all powered by Rocket and WebAssembly. As a reminder, a full example of the above tutorial is available on Codeberg. If you try something similar, I’d love to hear about it—and if there’s something deeply flawed in my approach, I’d be happy to be corrected. Thanks for reading!