Skip to main content

Command Palette

Search for a command to run...

How to Create a JSX-Like Rust Macro: Step-by-Step Guide - Part 3

Updated
6 min read

Previous Reads to Creating a Web Framework:

Quick note: this isn’t “Part Three” of the JSX-macro series. If that’s what you were expecting, it’ll have to wait—I’ll circle back when the design is ready. For now, I need forward motion, and writing a Rust macro without a concrete spec is a time sink.

The interim plan is simple: switch to Tera, an HTML-centric templating engine. It keeps development moving while the macro design firms up.

My goals stay the same—ship a polished portfolio site and the Rust framework behind it. The outline that follows walks through the setup: Axum for routing, Tera for rendering, and a clear on-ramp to refactor into macros once the architecture is locked.

Preface

This isn’t another JavaScript-heavy framework recap. If that’s what you expected, you’re in the wrong house of cards. We’re talking about a Rust-first approach that still plays nicely with web standards.

Server-first foundation

Our framework leans on Axum with Rust nightly to handle all HTTP requests. Handlers interpret incoming data using Axum’s extractors—State, Path, Query, Form, and Json—then return a type that implements IntoResponse. This keeps routes focused and predictable. Progressive enhancement is the rule: HTML works on its own, and JavaScript simply sweetens the deal.

pub async fn serve_index_page_handler(State(app_state): State<Arc<AppState>>) -> impl IntoResponse {
    let tera = &app_state.templates;
    let mut context = Context::new();
    let page_data = IndexPageData {
        title: "Auteur.Engineer (from index_handler)",
        heading: "Welcome to Auteur.Engineer",
        message: "This is a message for Autuer from the index_handler",
        show_extra_info: true,
    };
    context.insert("page", &page_data);

    match tera.render("index.html", &context) {
        Ok(html) => Html(html).into_response(),
        Err(err) => {
            eprintln!("Template rendering error: {:?}", err);
            (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to render template: {}", err)).into_response()
        }
    }
}

Endpoints & Handlers for data access

// post_handlers.rs
pub async fn get_post_handler(
    Extension(state): Extension<AppState>,
    Path(id): Path<String>,
) -> Result<Json<Post>, StatusCode> {
    let id = id.parse::<Thing>().map_err(|_| StatusCode::BAD_REQUEST)?;
    let mut res = state.db.query("SELECT * FROM post WHERE id = $id")
        .bind(("id", &id))
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let post: Option<Post> = res.take(0).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    post.map(Json).ok_or(StatusCode::NOT_FOUND)
}

pub async fn list_posts_handler(
    Extension(state): Extension<AppState>,
) -> Result<Json<Vec<Post>>, StatusCode> {
    let mut res = state.db.query("SELECT * FROM post LIMIT 50").await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let posts: Vec<Post> = res.take(0).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(posts))
}

#[derive(Debug, Deserialize)]
pub struct CreatePostRequest {
    pub title: String,
    pub metadata: SeoMetadata,
}

pub async fn create_post_handler(
    Extension(state): Extension<AppState>,
    Json(input): Json<CreatePostRequest>,
) -> Result<Json<Post>, StatusCode> {
    let id = surrealdb::sql::Uuid::new_v4().to_string();
    let thing = Thing::from(("post", id));

    let post = Post {
        id: thing.clone(),
        title: input.title,
        metadata: input.metadata,
    };

    let created: Post = state.db
        .create(thing)
        .content(&post)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(created))
}

// main.rs
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Connect to SurrealDB
    let db = Surreal::new::<Client>("localhost:8000").await?;
    db.use_ns("app").use_db("db").await?;

    // Shared app state
    let state = AppState { db };

    // Build routes
    let app = Router::new()
        .route("/posts", get(list_posts_handler).post(create_post_handler))
        .route("/posts/:id", get(get_post_handler))
        .layer(Extension(state));

    // Start server
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("🚀 Running at http://{addr}");
    axum::Server::bind(&addr).serve(app.into_make_service()).await?;

    Ok(())
}

View & Schema Models

schema_v2.rs shows how we leverage schemars to define JSON Schema from Rust types. Field metadata—like form widgets—travels from server to client, so you only specify it once. The same types drive data validation, API payloads, and forms.

use schemars::{schema_for, JsonSchema};
use schemars::schema::{Schema, SchemaObject};

fn textarea_widget_schema(g: &mut SchemaGenerator) -> Schema {
    let mut schema: SchemaObject = String::json_schema(g).into_object();
    schema.extensions.insert("widget".to_string(), json!("textarea"));
    Schema::Object(schema)
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Page {
    #[schemars(skip)]
    pub id: Option<Thing>,
    pub metadata: SeoMetadata,
}

CMS Authoring:

We use this JSON Schema for a Developer‑focused backend & a User‑focused frontend. Our CMS runs locally or in the cloud for a coder-friendly development flow. And it's easy on the eyes for content editors.

Figure: For Placement Only

Instant Authoring & Previews:

CMS edits are stored in "draft" mode. Preview changes right away side-by-side.

Figure: For Placement Only

We use ViewModel to shape raw data to be put into HTML Tera templates to then be rendered.

let page = Page {
    id: None,
    metadata: SeoMetadata {
        title:        Some("Hello".into()),
        description:  Some("desc".into()),
        canonical:    Some("https://example.com".parse().unwrap()),
        viewport:     None,
    },
};

pub fn adapt_blog_post_to_view_model(post: &Page) -> Result<Page, AdapterError> {
    Ok(page)
}

Performance mindset

Because Rust compiles to efficient machine code, you get predictable response times. The framework also encourages cautious use of JavaScript; many pages render fully via server-side HTML, so there’s less overhead on mobile devices.

#[tokio::main]
async fn main() {
    let tera_instance = match Tera::new("website/src/templates/**/*.html") {
        Ok(t) => { println!("Tera templates loaded successfully."); t }
        Err(e) => { eprintln!("FATAL: Parsing error(s) on template initialization: {}", e); ::std::process::exit(1); }
    };
    let shared_tera = Arc::new(tera_instance);

    let db = match Surreal::new::<Ws>("127.0.0.1:8000").await {
        Ok(db_instance) => { println!("Successfully initiated SurrealDB connection."); db_instance }
        Err(e) => { eprintln!("FATAL: Could not connect to SurrealDB: {:?}", e); ::std::process::exit(1); }
    };
    ...
}

Developer experience

Type-safe schemas reduce runtime errors and help editors offer better autocomplete suggestions. Static types w/schemars + Serde macros also allow us to create JSONSchema for our CMS and save data to the database. For local component development, snapshot testing improves visibility during development. Additionally, we'll have a Storybook-like environment to build components in isolation.

#[cfg(test)]
mod html_snapshots {
    use super::*;
    use insta::assert_snapshot;
    use tera::{Context, Tera};
    use crate::schema_v2::{Page, RobotsMeta, SeoMetadata};

    #[test]
    fn seo_macro_renders_expected_html() {
        let page = Page { id: None, metadata: SeoMetadata { title: Some("Hello".into()), description: Some("desc".into()), canonical: Some("https://example.com".parse().unwrap()), viewport: None, images: vec![], published_time: None, modified_time: None, expiration_time: None, authors: vec![], section: None, tags: vec![], robots: Some(RobotsMeta::default()), open_graph: None, twitter: None, alternates: vec![], schema_org: None } };

        let mut tera = Tera::default();
        tera.add_raw_template("macros/seo.html", include_str!("templates/seo/macros.html")).unwrap();
        tera.add_raw_template("snippet.html", r#"{% import "macros/seo.html" as macros -%}{{ macros::seo(meta=meta) -}}"#).unwrap();

        let mut ctx = Context::new();
        ctx.insert("meta", &page.metadata);

        let html = tera.render("snippet.html", &ctx).unwrap();
        assert_snapshot!("seo_macro__minimal_case", html);
    }
}
---
source: website/src/macro_test.rs
expression: html
---

<title>Hello</title>
<meta name="description" content="desc">
<link rel="canonical" href="https:&#x2F;&#x2F;example.com&#x2F;">
<meta name="robots" content="index,follow">
<meta property="og:type"        content="website">
<meta property="og:title"       content="Hello">
<meta property="og:description" content="desc">
<meta name="twitter:card"        content="summary_large_image">
<meta name="twitter:title"       content="Hello">
<meta name="twitter:description" content="desc">

Building Components

We favor the web’s core pillars: semantic HTML, small CSS bundles, and minimal JavaScript. Web Components add interactivity where needed without locking you into a single front-end stack.

{% import "macros.html" as forms %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ page.title }}</title>
    <link href="/styles.css" rel="stylesheet" />
    <script defer type="module" src="/main.js"></script>
</head>
<body>
 {% forms::create_post(submit_text=page.data.submit_button_text) %}
</body>
</html>
{% macro create_post(submit_text) %}
<form is="create-post-form" id="create-post-form" >
  <label for="title">Title</label>
  <input
    type="text"
    id="title"
    name="title"
    placeholder="Enter post title"
    required
  />
  <button type="submit">{{submit_text}}</button>
</form>

<script defer type="module">
  import {useStore} from './signal.js'
  // Custom element class that extends <form>
  class CreatePostForm extends HTMLFormElement {
    connectedCallback() {
      this.addEventListener('submit', this._onSubmit);
    }

    disconnectedCallback() {
      this.removeEventListener('submit', this._onSubmit);
    }

    _onSubmit = async (e) => {
      e.preventDefault();

      // Grab the input inside this form (keeps things encapsulated)
      const titleInput = this.querySelector('#title');
      const title = titleInput?.value.trim();

      if (!title) {
        alert('Title cannot be empty');
        return;
      }

      // … your async logic here …
      console.log('Submitting title:', title);
    };
  }

  // Register the element: name, class, and the built-in it extends
  customElements.define('create-post-form', CreatePostForm, { extends: 'form' });
</script>

{% endmacro %}

Next Up

  • Better summary of getting data from DB to Frontend Components and Back

  • Storybook like local env testing

  • O11Y

  • End user Tracking

  • Service Workers or Tauri

  • i18n

  • JS & CSS Bundling & Code Splitting

  • Compression & Caching

  • Search