Server-Side Rendering com Templates

Construa uma aplicacao web com Axum e templates Askama em Rust com renderizacao server-side, heranca de layouts, formularios HTML e mensagens flash.

Server-Side Rendering (SSR) e a tecnica de gerar HTML completo no servidor antes de envia-lo ao navegador. Diferentemente de SPAs (Single Page Applications), onde o JavaScript constroi a pagina no cliente, o SSR entrega paginas prontas para exibicao — resultando em carregamento mais rapido, melhor SEO e acessibilidade. Em Rust, a combinacao do framework web Axum com a engine de templates Askama oferece SSR com desempenho excepcional, pois os templates sao compilados junto com a aplicacao, eliminando overhead de interpretacao em tempo de execucao.

Neste projeto, vamos construir uma aplicacao web completa com renderizacao server-side. Voce aprendera a definir layouts reutilizaveis, renderizar paginas dinamicas, processar formularios HTML e exibir mensagens flash para o usuario. E um projeto essencial para quem quer desenvolver aplicacoes web com Rust de forma produtiva.

O Que Vamos Construir

Nossa aplicacao web tera os seguintes recursos:

  • Servidor HTTP com Axum e rotas organizadas
  • Templates Askama com compilacao em tempo de build
  • Layout base com heranca de templates (header, footer, conteudo)
  • Pagina inicial com listagem dinamica de itens
  • Formulario HTML para adicionar novos itens
  • Mensagens flash (sucesso, erro) entre requisicoes
  • Paginas de erro personalizadas (404)
  • CSS embutido para estilizacao basica

Estrutura do Projeto

ssr-templates/
├── Cargo.toml
├── templates/
│   ├── base.html
│   ├── inicio.html
│   ├── formulario.html
│   ├── sucesso.html
│   └── erro404.html
└── src/
    ├── main.rs
    ├── rotas.rs
    ├── modelos.rs
    └── estado.rs

Configurando o Projeto

cargo new ssr-templates
cd ssr-templates
mkdir templates

Configure o Cargo.toml:

[package]
name = "ssr-templates"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
askama = "0.12"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
tower-http = { version = "0.5", features = ["fs"] }

Usamos axum como framework web assincrono, askama para templates compilados em tempo de build, tokio como runtime assincrono e serde para desserializacao de formularios.

Passo 1: Modelos de Dados e Estado da Aplicacao

Comecamos definindo o modelo de dados e o estado compartilhado. O estado usa Arc<Mutex<>> para ser seguro entre threads — padrao comum em aplicacoes Axum.

Crie o arquivo src/modelos.rs:

// src/modelos.rs
use serde::Deserialize;

/// Representa um item na nossa aplicacao
#[derive(Clone, Debug)]
pub struct Item {
    pub id: u32,
    pub titulo: String,
    pub descricao: String,
    pub criado_em: String,
}

/// Dados recebidos do formulario de criacao
#[derive(Deserialize)]
pub struct FormularioItem {
    pub titulo: String,
    pub descricao: String,
}

Agora crie src/estado.rs com o estado compartilhado:

// src/estado.rs
use crate::modelos::Item;
use std::sync::{Arc, Mutex};

/// Estado compartilhado da aplicacao
#[derive(Clone)]
pub struct EstadoApp {
    pub itens: Arc<Mutex<Vec<Item>>>,
    pub proximo_id: Arc<Mutex<u32>>,
    pub mensagem_flash: Arc<Mutex<Option<MensagemFlash>>>,
}

/// Mensagem flash exibida uma unica vez
#[derive(Clone, Debug)]
pub struct MensagemFlash {
    pub tipo: TipoFlash,
    pub texto: String,
}

#[derive(Clone, Debug)]
pub enum TipoFlash {
    Sucesso,
    Erro,
}

impl EstadoApp {
    /// Cria um novo estado com dados de exemplo
    pub fn novo() -> Self {
        let itens = vec![
            Item {
                id: 1,
                titulo: "Aprender Rust".to_string(),
                descricao: "Estudar ownership, borrowing e lifetimes".to_string(),
                criado_em: "2026-02-20".to_string(),
            },
            Item {
                id: 2,
                titulo: "Construir API REST".to_string(),
                descricao: "Criar uma API com Axum e banco de dados".to_string(),
                criado_em: "2026-02-22".to_string(),
            },
        ];

        Self {
            itens: Arc::new(Mutex::new(itens)),
            proximo_id: Arc::new(Mutex::new(3)),
            mensagem_flash: Arc::new(Mutex::new(None)),
        }
    }

    /// Adiciona um novo item e retorna o ID gerado
    pub fn adicionar_item(&self, titulo: String, descricao: String) -> u32 {
        let mut id_guard = self.proximo_id.lock().unwrap();
        let id = *id_guard;
        *id_guard += 1;

        let item = Item {
            id,
            titulo,
            descricao,
            criado_em: "2026-02-24".to_string(),
        };

        self.itens.lock().unwrap().push(item);
        id
    }

    /// Define uma mensagem flash para a proxima requisicao
    pub fn definir_flash(&self, tipo: TipoFlash, texto: String) {
        let mut flash = self.mensagem_flash.lock().unwrap();
        *flash = Some(MensagemFlash { tipo, texto });
    }

    /// Consome e retorna a mensagem flash (exibida uma unica vez)
    pub fn consumir_flash(&self) -> Option<MensagemFlash> {
        self.mensagem_flash.lock().unwrap().take()
    }
}

O padrao de mensagem flash e muito usado em aplicacoes web: apos uma acao (como criar um item), armazenamos uma mensagem que sera exibida na proxima pagina e depois descartada.

Passo 2: Definindo os Templates Askama

Os templates Askama usam uma sintaxe semelhante a Jinja2. O grande diferencial e que eles sao verificados e compilados em tempo de build — se voce cometer um erro de sintaxe no template, o compilador Rust vai acusar o erro antes mesmo de executar a aplicacao.

Crie templates/base.html — o layout base herdado por todas as paginas:

<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block titulo %}Minha App{% endblock %}</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        nav { background: #2c3e50; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
        nav a { color: white; text-decoration: none; margin-right: 20px; font-weight: bold; }
        nav a:hover { text-decoration: underline; }
        .conteudo { background: white; padding: 25px; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
        .flash-sucesso { background: #d4edda; color: #155724; padding: 12px; border-radius: 4px; margin-bottom: 15px; }
        .flash-erro { background: #f8d7da; color: #721c24; padding: 12px; border-radius: 4px; margin-bottom: 15px; }
        .item { border-bottom: 1px solid #eee; padding: 15px 0; }
        .item:last-child { border-bottom: none; }
        .item h3 { margin: 0 0 5px 0; color: #2c3e50; }
        .item p { margin: 0; color: #666; }
        .item .data { font-size: 0.85em; color: #999; }
        form label { display: block; margin-top: 10px; font-weight: bold; }
        form input, form textarea { width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
        form textarea { height: 100px; resize: vertical; }
        form button { margin-top: 15px; padding: 10px 25px; background: #2c3e50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; }
        form button:hover { background: #34495e; }
        footer { text-align: center; margin-top: 30px; color: #999; font-size: 0.9em; }
    </style>
</head>
<body>
    <nav>
        <a href="/">Inicio</a>
        <a href="/novo">Novo Item</a>
    </nav>
    <div class="conteudo">
        {% block conteudo %}{% endblock %}
    </div>
    <footer>
        <p>Construido com Rust, Axum e Askama</p>
    </footer>
</body>
</html>

Crie templates/inicio.html:

{% extends "base.html" %}

{% block titulo %}Inicio - Meus Itens{% endblock %}

{% block conteudo %}
{% if let Some(flash) = flash %}
    {% if flash.sucesso %}
    <div class="flash-sucesso">{{ flash.texto }}</div>
    {% else %}
    <div class="flash-erro">{{ flash.texto }}</div>
    {% endif %}
{% endif %}

<h1>Meus Itens</h1>

{% if itens.is_empty() %}
    <p>Nenhum item cadastrado. <a href="/novo">Adicione o primeiro!</a></p>
{% else %}
    <p>Total: {{ itens.len() }} item(ns)</p>
    {% for item in itens %}
    <div class="item">
        <h3>#{{ item.id }} - {{ item.titulo }}</h3>
        <p>{{ item.descricao }}</p>
        <span class="data">Criado em: {{ item.criado_em }}</span>
    </div>
    {% endfor %}
{% endif %}
{% endblock %}

Crie templates/formulario.html:

{% extends "base.html" %}

{% block titulo %}Novo Item{% endblock %}

{% block conteudo %}
<h1>Adicionar Novo Item</h1>

{% if let Some(ref erro) = erro %}
    <div class="flash-erro">{{ erro }}</div>
{% endif %}

<form method="post" action="/novo">
    <label for="titulo">Titulo:</label>
    <input type="text" id="titulo" name="titulo" required
           placeholder="Digite o titulo do item">

    <label for="descricao">Descricao:</label>
    <textarea id="descricao" name="descricao" required
              placeholder="Descreva o item em detalhes"></textarea>

    <button type="submit">Adicionar Item</button>
</form>
{% endblock %}

Crie templates/sucesso.html:

{% extends "base.html" %}

{% block titulo %}Item Criado{% endblock %}

{% block conteudo %}
<h1>Item Criado com Sucesso!</h1>
<p>O item <strong>#{{ id }} - {{ titulo }}</strong> foi adicionado.</p>
<p><a href="/">Voltar para a lista</a></p>
{% endblock %}

Crie templates/erro404.html:

{% extends "base.html" %}

{% block titulo %}Pagina Nao Encontrada{% endblock %}

{% block conteudo %}
<h1>404 - Pagina Nao Encontrada</h1>
<p>A pagina que voce procura nao existe.</p>
<p><a href="/">Voltar para o inicio</a></p>
{% endblock %}

A heranca de templates funciona com {% extends "base.html" %}: cada pagina herda a estrutura do layout base e preenche apenas os blocos {% block %} que precisa personalizar.

Passo 3: Rotas e Handlers

O modulo rotas.rs define os handlers HTTP que processam as requisicoes e renderizam os templates.

// src/rotas.rs
use askama::Template;
use axum::{
    extract::State,
    http::StatusCode,
    response::{Html, IntoResponse, Redirect},
    Form,
};

use crate::estado::{EstadoApp, TipoFlash};
use crate::modelos::{FormularioItem, Item};

/// Dados da mensagem flash para o template
pub struct FlashInfo {
    pub sucesso: bool,
    pub texto: String,
}

/// Template da pagina inicial
#[derive(Template)]
#[template(path = "inicio.html")]
pub struct TemplateInicio {
    pub itens: Vec<Item>,
    pub flash: Option<FlashInfo>,
}

/// Template do formulario
#[derive(Template)]
#[template(path = "formulario.html")]
pub struct TemplateFormulario {
    pub erro: Option<String>,
}

/// Template de sucesso
#[derive(Template)]
#[template(path = "sucesso.html")]
pub struct TemplateSucesso {
    pub id: u32,
    pub titulo: String,
}

/// Template de erro 404
#[derive(Template)]
#[template(path = "erro404.html")]
pub struct TemplateErro404;

/// Handler: GET / — pagina inicial com listagem de itens
pub async fn pagina_inicial(
    State(estado): State<EstadoApp>,
) -> impl IntoResponse {
    let itens = estado.itens.lock().unwrap().clone();

    // Consome a mensagem flash, se houver
    let flash = estado.consumir_flash().map(|f| FlashInfo {
        sucesso: matches!(f.tipo, TipoFlash::Sucesso),
        texto: f.texto,
    });

    let template = TemplateInicio { itens, flash };
    match template.render() {
        Ok(html) => Html(html).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

/// Handler: GET /novo — exibe o formulario de criacao
pub async fn exibir_formulario() -> impl IntoResponse {
    let template = TemplateFormulario { erro: None };
    match template.render() {
        Ok(html) => Html(html).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

/// Handler: POST /novo — processa o formulario de criacao
pub async fn processar_formulario(
    State(estado): State<EstadoApp>,
    Form(dados): Form<FormularioItem>,
) -> impl IntoResponse {
    // Validacao dos campos
    let titulo = dados.titulo.trim().to_string();
    let descricao = dados.descricao.trim().to_string();

    if titulo.is_empty() || descricao.is_empty() {
        let template = TemplateFormulario {
            erro: Some("Todos os campos sao obrigatorios.".to_string()),
        };
        return match template.render() {
            Ok(html) => Html(html).into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        };
    }

    if titulo.len() > 100 {
        let template = TemplateFormulario {
            erro: Some("O titulo deve ter no maximo 100 caracteres.".to_string()),
        };
        return match template.render() {
            Ok(html) => Html(html).into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        };
    }

    // Cria o novo item
    let id = estado.adicionar_item(titulo.clone(), descricao);

    // Define mensagem flash de sucesso
    estado.definir_flash(
        TipoFlash::Sucesso,
        format!("Item '{}' criado com sucesso!", titulo),
    );

    let template = TemplateSucesso { id, titulo };
    match template.render() {
        Ok(html) => Html(html).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

/// Handler: pagina 404 para rotas nao encontradas
pub async fn pagina_nao_encontrada() -> impl IntoResponse {
    let template = TemplateErro404;
    match template.render() {
        Ok(html) => (StatusCode::NOT_FOUND, Html(html)).into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

Cada handler segue o mesmo padrao: monta a struct do template com os dados necessarios, chama .render() e retorna o HTML. A validacao do formulario e feita no handler processar_formulario — se os dados forem invalidos, reexibimos o formulario com a mensagem de erro.

Passo 4: Juntando Tudo no main.rs

// src/main.rs
mod estado;
mod modelos;
mod rotas;

use axum::{routing::get, Router};
use estado::EstadoApp;

#[tokio::main]
async fn main() {
    // Inicializa o estado da aplicacao
    let estado = EstadoApp::novo();

    // Define as rotas da aplicacao
    let app = Router::new()
        .route("/", get(rotas::pagina_inicial))
        .route(
            "/novo",
            get(rotas::exibir_formulario).post(rotas::processar_formulario),
        )
        .fallback(rotas::pagina_nao_encontrada)
        .with_state(estado);

    // Inicia o servidor na porta 3000
    let endereco = "0.0.0.0:3000";
    println!("Servidor iniciado em http://localhost:3000");
    println!("Pressione Ctrl+C para encerrar.");

    let listener = tokio::net::TcpListener::bind(endereco)
        .await
        .expect("Erro ao iniciar o servidor");

    axum::serve(listener, app)
        .await
        .expect("Erro ao executar o servidor");
}

O main.rs e enxuto: cria o estado, define as rotas com seus handlers, configura o fallback para 404 e inicia o servidor Axum. A macro #[tokio::main] converte a funcao main em assincrona, permitindo o uso de await.

Como Executar

# Compilar e executar a aplicacao
cargo run

# Saida esperada:
# Servidor iniciado em http://localhost:3000
# Pressione Ctrl+C para encerrar.

Abra o navegador e acesse as paginas:

# Pagina inicial com listagem de itens
# http://localhost:3000/

# Formulario para adicionar novo item
# http://localhost:3000/novo

# Testar com curl — pagina inicial
curl http://localhost:3000/

# Testar criacao de item via curl
curl -X POST http://localhost:3000/novo \
  -d "titulo=Meu+Item&descricao=Descricao+do+item"

# Pagina 404 para rotas inexistentes
curl http://localhost:3000/pagina-que-nao-existe

Ao acessar /, voce vera a lista de itens pre-cadastrados. Ao acessar /novo, um formulario HTML sera exibido. Ao submeter o formulario, o item sera criado e uma pagina de confirmacao sera exibida. Ao retornar para /, a mensagem flash de sucesso aparecera no topo da pagina.

Desafios para Expandir

  1. Remocao e edicao de itens: Adicione rotas POST /remover/:id e GET /editar/:id com formulario preenchido, implementando o CRUD completo. Use o extrator Path do Axum para capturar o ID da URL.

  2. Persistencia com banco de dados: Substitua o Vec<Item> em memoria por um banco SQLite usando a crate sqlx ou rusqlite, garantindo que os dados sobrevivam ao reinicio do servidor.

  3. Paginacao: Implemente paginacao na listagem com parametros de query string (?pagina=2&por_pagina=10), renderizando links de navegacao no template.

  4. Arquivos estaticos: Configure o middleware ServeDir do tower-http para servir arquivos CSS, JavaScript e imagens de um diretorio static/, separando o estilo do HTML.

  5. Autenticacao basica: Adicione um sistema de login simples com sessoes usando cookies, protegendo as rotas de criacao e edicao. Use a crate tower-cookies para gerenciar sessoes.

Veja Tambem