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.


©2026 Megalithic LLC | Website | GitLab | Contact