Introduction
Auric is an Ember-inspired web application framework for building modern applications in Rust that compiles to WASM. Auric emphasizes the MVC separation of concerns, where:
- Visual Designers maintain an application skin using Handlebars templates
- Developers maintain controllers, components, models, and adapters that keep code out of templates
An Ember Experience in Rust
Auric provides a way for you to write single-page applications using your favorite Ember design patterns, including Models, Views (templates), Controllers, Components, and Adapters, using type-safe async Rust.
Auric's build process combines those assets into a code-generated Leptos webapp that compiles to WASM for superior performance and security.
Key Technologies Used
Auric integrates the following key technologies:
- Trunk, for WASM bundling, and for supporting development sessions with live page-reload
- Leptos, for rendering and interactivity
- Ember Data (re-imagined in Rust), for in-memory model lifecycle management, and for JSON:API based relational storage middleware that makes it possible to implement adapters for REST, JSON:API, GraphQL, gRPC, or anything else.
Installing
First ensure Rust is installed. Details here.
Next, ensure the wasm32-unknown-unknown Rust platform target is installed so that Rust can compile
to WebAssembly.
$ rustup target add wasm32-unknown-unknown
Next, install the Auric CLI:
$ cargo install auric
Finally, install Trunk:
$ cargo install --locked trunk
You're now ready to create your first Auric application:
$ auric new my-app
==> make
2026-08-13T14:20:39.039679Z INFO 🚀 Starting trunk 0.21.14
2026-08-13T14:20:40.474657Z INFO 📦 starting build
...
Compiling auric-build v0.1.4
Compiling auric-runtime v0.1.4
Compiling handlebars v6.4.4
Compiling js-sys v0.3.103
Compiling console_error_panic_hook v0.1.7
Compiling web-sys v0.3.104
Compiling leptos v0.8.4
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.25s
$ cd my-app
$ auric serve --open
2026-07-04T14:57:49.311838Z INFO 🚀 Starting trunk 0.21.14
2026-07-04T14:57:49.385672Z INFO 📦 starting build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
2026-07-04T14:57:49.734082Z INFO applying new distribution
2026-07-04T14:57:49.734841Z INFO ✅ success
2026-07-04T14:57:49.734883Z INFO 📡 serving static assets at -> /
2026-07-04T14:57:49.734931Z INFO 📡 server listening at:
2026-07-04T14:57:49.734935Z INFO 🏠 http://127.0.0.1:4200/
2026-07-04T14:57:49.734937Z INFO 🏠 http://[::1]:4200/
2026-07-04T14:57:49.735070Z INFO 🏠 http://localhost.:4200/
Templates
Templates contain Handlebars markup laying out a snippet of HTML.
The Application Template
Every Auric application has an application template at src/templates/application.hbs, and its
contents are displayed at all times. This template establishes the outer-most structure of
your application layout.
The default content looks like this:
<h1>Welcome to Auric SPA!</h1>
{{outlet}}
When you build your application, a Leptos component is generated for the application template. This
code representation of a template is called the View.
$ make
$ cat src/generated/views/application.rs
use leptos::prelude::*;
#[allow(unused)]
use leptos_router::{path, components::*};
#[allow(unused)]
use auric_runtime::components::*;
use crate::generated::views::{
index::IndexView,
};
#[component]
pub fn ApplicationView() -> impl IntoView {
view! {
<Router>
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<Route path=path!("/*any") view=|| view! { <h1>Not found.</h1> } />
</Routes>
<h1>Welcome to Auric SPA!</h1>
<div />
</Router>
}
}
The Index Template
Every Auric application also has an implicit Index route, and associated template containing default
{{outlet}} content. This is required by the Leptos router, which needs an IndexView to associate
with the root route having path="/".
The implicit index template can be replaced with an explicit one, if you need to control the content:
$ auric generate template index
created src/templates/index.hbs
And likewise, the implicit index route can be replaced with an explicit one:
$ auric generate route index
created src/routes/index.rs
updated src/routes/mod.rs
created src/templates/index.hbs
Other Templates
Let's define a new route to display a list of customers:
$ auric generate route customers
created src/routes/customers.rs
updated src/routes/mod.rs
created src/templates/customers.hbs
==> make
Compiling drr v0.1.0 (/Users/davidr/workspaces/drr)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.70s
The default content for a new template is simply {{outlet}}. But let's update the template to display a
list of customers:
<h2>Customers:</h2>
{{#each this.model.customers as |customer|}}
<li>{{customer.name}}</li>
{{/each}}
The Route at src/routes/customers.rs would then define a model() function to load the list of customers
from the Store.
NOTE:
Route
model()functions that return Store models is not implemented yet.
When you build your application, the Leptos CustomersView component is generated, and looks like this:
$ cat src/generated/views/customers.rs
use leptos::prelude::*;
#[allow(unused)]
use auric_runtime::components::*;
#[component]
pub fn CustomersView() -> impl IntoView {
view! {
<h2>Customers:</h2>
...
}
}
Performance and Security
You might have noticed that Handlebars templates are only used at compile-time, and they are not bundled within your web application's wasm bundle, or rendered at runtime. Instead, they are translated at build time into Leptos components, which are compiled and statically linked into your wasm bundle. This design ensures that views are hardened, performant, and async.
Components
A component is like your own custom HTML tag. It provides a way for you to centralize and re-use a snippet of HTML.
Let's generate a component to layout a customer record using a Bootstrap card class:
$ auric generate component customer-card
created src/components/customer_card.hbs
==> make
Compiling ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.91s
The initial content of src/components/customer_card.hbs is simply {{yield}}. Let's customize
it:
<div class="card text-center">
<div class="card-body">
{{yield}}
</div>
</div>
If we rebuid our project, we can see that a Leptos component was generated for our component
at src/generated/components/customer_card.rs:
$ make
Compiling ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.91s
$ cat src/generated/components/customer_card.rs
use leptos::prelude::*;
#[component]
pub fn CustomerCardComponent(children: Children) -> impl IntoView {
view! {
<div class="card text-center">
<div class="card-body">
{children()}
</div>
</div>
}
}
Now let's reference our new customer card component from another view:
{{!-- src/templates/customers.hbs --}}
<CustomerCard>
Acme Widgets
</CustomerCard>
<CustomerCard>
Standard Paper Supplies, Inc.
</CustomerCard>
If we rebuild our project, we can examine the Leptos component that was generated for the customers view, which now contains the references to the customer card component:
$ make
Compiling ...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.91s
$ cat src/generated/views/customers.rs
use leptos::prelude::*;
#[allow(unused)]
use auric_runtime::components::*;
#[allow(unused)]
use crate::generated::components::{
customer_card::CustomerCardComponent,
};
#[component]
pub fn CustomersView() -> impl IntoView {
view! {
<CustomerCardComponent>
Acme Widgets
</CustomerCardComponent>
<CustomerCardComponent>
Standard Paper Supplies, Inc.
</CustomerCardComponent>
}
}
Routing
A URL can be set in the following ways:
- The user loads the app for the first time.
- The user changes the URL manually, by clicking on a back or forward button, or by editing the address bar.
- The user clicks on a link within the app.
When the URL changes, the Auric framework:
- finds the route handler that you have provided for the given route
- calls its
model()function - renders the route's template, which can include references to the model
Defining Routes
When your application starts, the Leptos router matches the current URL to the routes that you've defined. The routes, in turn, are responsible for loading data, and setting up application state.
Basic Routes
Let's generate a new route:
$ auric generate route invoices
created src/routes/invoices.rs
updated src/routes/mod.rs
created src/templates/invoices.hbs
==> make
Compiling my-app v0.1.0 (/Users/davidr/workspaces/my-app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.73s
This will generate the following file:
// src/routes/invoices.rs
use anyhow::Result;
use auric_runtime::async_trait::async_trait;
use auric_runtime::routing::Route;
use auric_runtime::{AuricRoute, Context};
use serde_json::{Value, json};
#[derive(AuricRoute, Default)]
#[route("/invoices")]
pub struct InvoicesRoute {}
#[async_trait]
impl Route for InvoicesRoute {
fn init(&mut self, _context: &dyn Context) -> Result<()> {
Ok(())
}
async fn model(&self, _params: &[Value]) -> Result<RouteModel> {
Ok(RouteModel::Json(json!({})))
}
}
#[cfg(test)]
mod tests {
use super::InvoicesRoute;
#[test]
fn can_construct() {
let _ = InvoicesRoute::default();
}
}
The Auric build process also generates an ApplicationView that includes the Leptos Router and its
Route definitions. Let's have a look:
// src/generated/views/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
view! {
<Router>
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=IndexView />
<Route path=path!("/invoices") view=InvoicesView />
<Route path=path!("/*any") view=|| view! { <h1>Not found.</h1> } />
</Routes>
<h1>Welcome to Auric SPA!</h1>
<div />
</Router>
}
}
Nested Routes
TODO
Index Routes
TODO
Linking Between Routes
Establishing links to different parts of your application can be done with the LinkTo component.
Let's link to an invoices page from a navbar in our application template:
// src/templates/application.hbs
<h1>Links:</h1>
<LinkTo @route="customers">
<button class="btn btn-default">Customers</button>
</LinkTo>
<LinkTo @route="invoices">
<button class="btn btn-default">Invoices</button>
</LinkTo>
{{outlet}}
This will generate the following file:
// src/generated/application.rs
...
#[component]
pub fn ApplicationView() -> impl IntoView {
view! {
<Router>
<Routes fallback=|| "Not found.">
...
</Routes>
<h1>Links:</h1>
<LinkTo path="customers".to_string()>
<button class="btn btn-default">Customers</button>
</LinkTo>
<LinkTo path="invoices".to_string()>
<button class="btn btn-default">Invoices</button>
</LinkTo>
<div />
</Router>
}
}
Specifying a Route's Model
You define a route's model by implementing its model() function.
Static Models
Let's return a simple model:
// src/routes/customers.rs
...
#[async_trait]
impl Route for CustomersRoute {
...
async fn model(&self, _params: &[Value]) -> Result<RouteModel> {
Ok(RouteModel::Json(json!({
"name": "Acme Widgets",
"totalMonthlySpend": 2300,
})))
}
}
You can now reference the model values from your route template:
{{!-- src/templates/customers.hbs --}}
<li>Name: {{this.model.name}}</li>
<li>Total Monthly Spend: {{this.model.totalMonthlySpend}}</li>
Fetching Data
To fetch data from your model() function, first customize your route initialization to
save a reference to the application-wide Store:
// src/routes/customers.rs
#[derive(AuricRoute, Default)]
#[route("/customers")]
pub struct CustomersRoute {
store: Arc<Store>,
}
#[async_trait]
impl Route for CustomersRoute {
fn init(&mut self, context: &dyn Context) -> Result<()> {
self.store = context.store();
Ok(())
}
}
Now you can use the store from your model() function:
// src/routes/customers.rs
#[async_trait]
impl Route for CustomersRoute {
...
async fn model(&self, _params: &[Value]) -> Result<RouteModel> {
let customers: Vec<Customer> = self.store.find_all("customer", None).await?;
...
}
}
Data
Auric has a data framework that is equivalent to Ember Data, enabling you to define Models that work with a back-end through adapters for REST, JSON:API, GraphQL, gRPC, or anything else.
Defining the Application Adapter
The first step in setting up data access is to generate an Application adapter. This establishes a default adapter for the Store to use, which can be supplemented later by additional model-specific adapters.
Let's generate a REST-based Application adapter:
$ auric generate adapter application --target rest
created src/adapters/application.rs
updated src/adapters/mod.rs
==> make
Compiling drr v0.1.0 (/Users/davidr/workspaces/drr)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.47s
This will create the following file:
// src/adapters/application.rs
use auric_runtime::{
Config,
async_trait::async_trait,
data::{Adapter, QueryRecordOptions, adapters::RestAdapter},
};
use jsonapi_core::Resource;
#[derive(Default)]
pub struct ApplicationAdapter {
target: RestAdapter,
}
#[async_trait(?Send)]
impl Adapter for ApplicationAdapter {
fn init(&mut self, config: &Config) -> Result<()> {
self.target.init(config)
}
async fn create_record(&self, resource: Resource) -> Result<Resource> {
self.target.create_record(resource).await
}
async fn delete_record(&self, resource: &Resource) -> Result<()> {
self.target.delete_record(resource).await
}
async fn find_record(&self, resource_type: &str, id: &str) -> Result<Resource> {
self.target.find_record(resource_type, id).await
}
async fn query(&self, resource_type: &str, query: serde_json::Value) -> Result<Vec<Resource>> {
self.target.query(resource_type, query).await
}
async fn query_record(
&self,
resource_type: &str,
query: serde_json::Value,
options: QueryRecordOptions,
) -> Result<Option<Resource>> {
self.target
.query_record(resource_type, query, options)
.await
}
async fn update_record(&self, resource: Resource) -> Result<Resource> {
self.target.update_record(resource).await
}
}
#[cfg(test)]
mod tests {
use super::ApplicationAdapter;
use auric_runtime::data::Adapter;
use serde_json::json;
#[test]
fn can_construct() {
let _ = ApplicationAdapter::default();
}
#[test]
fn can_init() {
let mut adapter = ApplicationAdapter::default();
let config = json!({"api_url": "http://localhost:3000"});
adapter
.init(&config)
.expect("Expected to initialize with a valid api url");
}
#[test]
fn cannot_init_with_missing_api_url() {
let mut adapter = ApplicationAdapter::default();
let config = json!({"someStuff": "abc"});
assert!(
adapter.init(&config).is_err(),
"Expected init to fail due to missing api url",
);
}
}
As you can see, the implementation of each Adapter trait function simply delegates to a target RestAdapter, providing a default implementation upfront, while leaving you in a position to make future customizations.
Defining Models
A model struct defines the properties and behavior of the data presented to your user.
Let's generate a new model:
$ auric generate model customer
created src/models/customer.rs
updated src/models/mod.rs
==> make
Compiling drr v0.1.0 (/Users/davidr/workspaces/drr)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.47s
This will generate the following file:
// src/models/customer.rs
use jsonapi_core::JsonApi;
#[derive(Clone, Debug, Default, JsonApi, PartialEq)]
#[jsonapi(type = "customers")]
pub struct Customer {
#[jsonapi(id)]
pub id: String,
}
#[cfg(test)]
mod tests {
use super::Customer;
#[test]
fn can_construct() {
let _ = Customer::default();
}
}
The full range of model features are documented in the jsonapi_core crate.
Let's add a few additional fields:
// src/models/customer.rs
use jsonapi_core::{JsonApi, Relationship};
use super::account::Account;
#[derive(Clone, Debug, Default, JsonApi, PartialEq)]
#[jsonapi(type = "customers")]
pub struct Customer {
#[jsonapi(id)]
pub id: String,
pub name: String,
#[jsonapi(relationship, type = "accounts")]
pub owner: Relationship<Account>,
}
Finding Records
The Store provides functions for retrieving records of a single type.
See Fetching Data in the routing guide for how to obtain a reference to the application-wide store.
Retrieving a Single Record
Use Store::find_record() to retrieve a record by its type and ID.
use crate::models::customer::Customer;
let customer: Customer = store.find_record("customer", "123", None).await?;
Use Store::peek_record() to retrieve a record by its type and ID, without making
a network request. This will return the record only if it is already present in the store's cache:
let maybe_customer: Option<Customer> = store.peek_record("customer", "123")?;
Retrieving Multiple Records
Use Store::find_all() to retrieve all of the records for a given type:
use crate::models::customer::Customer;
let customers: Vec<Customer> = store.find_all("customer", None).await?;
Use Store::peek_all() to retrieve all of the records for a given type that are already
loaded in the store, without making a network request:
use crate::models::customer::Customer;
let customers: Vec<Customer> = store.peek_all("customer")?;
Querying for Multiple Records
Use Store::query() to query for records that meet certain criteria.
use crate::models::customer::Customer;
use serde_json::json;
let customers: Vec<Customer> = store.query("customer", json!({
"filter": {
"name": "Acme Widgets",
},
})).await?;
Creating, Updating, and Deleting Records
See Fetching Data
in the routing guide for how to obtain a reference to the application-wide Store.
Creating Records
Use Store::create_record() to create a record.
use crate::models::customer::Customer;
let customer = Customer{
name: "Acme Widgets".to_string(),
..Default::default()
};
let finalized_customer = store.create_record("customer", &customer).await?;
log::info!("The assigned customer id is {}", finalized_customer.id);
Updating Records
TODO
Deleting Records
Use Store::delete_record() to delete a record:
use crate::models::customer::Customer;
let customer: Customer = store.find_record("customer", "123", None).await?;
store.delete_record(&customer).await?;
Pushing Records into the Store
The Store provides a cache of all the previously loaded records. This enables dynamic routing, including back-navigation, to see a consistent set of records, whether dirty or not, which has always been one of Ember's core strengths.
Records can be pushed into the cache ahead of time, to avoid later network operations.
See Fetching Data
in the routing guide for how to obtain a reference to the application-wide Store.
Pushing Records
Use Store::push() to push a record into the store.
use crate::models::customer::Customer;
let customer = Customer{
id: "123".to_string(),
name: "Acme Widgets".to_string(),
..Default::default()
};
store.push(customer)?;
Application Concerns
Configuring your Application
Configuration is performed within the src/config.rs file, which defines a configuration factory
function that returns a JSON object.
To customize your application build, use the env! and option_env! Rust macros to read environment
variables at build time. This will hard-code their final values into your sealed wasm bundle, ensuring
scrict release versioning and optimal security.
Configuring your Adapter
One common use case is the need to configure your application's adapters with the URL of your back-end API service. To achieve that, your configuration might look like this:
// src/config.rs
pub fn new() -> serde_json::Value {
serde_json::json!({
"api_url": option_env!("API_URL").unwrap_or("http://localhost:3000"),
})
}
or
// src/config.rs
pub fn new() -> serde_json::Value {
serde_json::json!({
"api_url": env!("API_URL").expect("an API_URL is required to build"),
})
}
At build time, you would provide the API URL during calls to make as follows for a test environment:
$ API_URL=https://api.test.yourapp.yourdomain make
or for a production environment:
$ API_URL=https://api.yourapp.yourdomain make
On application startup, adapters have their init() function invoked, which is where the config is passed
in as a parameter.