Rust and WASM for form validation
For a very long time, the Rust WASM story wasn't entirely clear to me. In order to use WASM from Rust you had to use Node, Webpack, and all that jazz. This always turned me off using WebAssembly because it felt too heavy of a toolchain to get going. These days, things are much, much better. 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. 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. This post is being written in early-to-mid 2025. And the stack I currently jive with is as follows: 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 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: 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. 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 Edit Let’s also pull in some dependencies while we’re editing that file: 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 Great, now that we’re producing a WASM library that can be loaded from a browser, it’s time to build the server. Let’s add a few libraries to our server’s 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 If you’ve worked with things like FastAPI the above most likely doesn’t shock you too much. We define a We then define the shape of the form data that we expect ( Note: 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 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 And now for our Shove both of these templates in a directory called 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! 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 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 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. The Next, we need to define the function that will be called by the 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 Next, we do another 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 The slightly awkward call to 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: This is why we had to create that 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 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!Why?
Stack
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
tutorial
├── server
└── wasm
&&
WASM setup
Installing wasm as a target
rustup target list --installed
.Configuring the wasm crate
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"]
[]
= "0.2"
[]
= "0.3"
= [
"Document",
"HtmlFormElement",
"HtmlInputElement",
"Window",
"console",
"Event",
]
pkg/
:
Server side implementation
Cargo.toml
:[]
= "0.5"
[]
= "0.2"
= ["tera"]
src/main.rs
, let’s add a some code to handle our fictional login. Anyone with the password “hunter2” will be allowed in.
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
.&'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.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.
/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.login.html.tera
:
Login
Email
Password
success.html.tera
:
Login successful
Welcome {{ username }}
You've successfully logged in!
templates
in your server
project, and Rocket should be able to start when you launch it:
>> ()
>> ()
>> ()
>> directory:
>> engines:
Adding more features to the WASM
wasm/src/lib.rs
, let’s replace the existing code with the following snippet:
.@e
as a valid email address).let doc = window.unwrap.document.unwrap;
let form = doc
.get_element_by_id
.unwrap
.
.unwrap;
let form_ref = form.clone;
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.onsubmit
event.let closure = new;
'static
, which is why we have that move
keyword in there. Any outside variable we reference will be moved into it.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.None
case), or if the domain is not example.com
, then we bail and show an error message.set_custom_validity
with an empty string is sadly how this browser API is expected to work.form.add_event_listener_with_callback?;
closure.forget;
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
unwrap
s to high heaven, and that’s nothing something I would condone in actual production code—please do use proper error handling.