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
Remocao e edicao de itens: Adicione rotas
POST /remover/:ideGET /editar/:idcom formulario preenchido, implementando o CRUD completo. Use o extratorPathdo Axum para capturar o ID da URL.Persistencia com banco de dados: Substitua o
Vec<Item>em memoria por um banco SQLite usando a cratesqlxourusqlite, garantindo que os dados sobrevivam ao reinicio do servidor.Paginacao: Implemente paginacao na listagem com parametros de query string (
?pagina=2&por_pagina=10), renderizando links de navegacao no template.Arquivos estaticos: Configure o middleware
ServeDirdotower-httppara servir arquivos CSS, JavaScript e imagens de um diretoriostatic/, separando o estilo do HTML.Autenticacao basica: Adicione um sistema de login simples com sessoes usando cookies, protegendo as rotas de criacao e edicao. Use a crate
tower-cookiespara gerenciar sessoes.
Veja Tambem
- Manipulacao de Strings — formatacao de texto em templates
- Modulo fs — leitura de arquivos de template do disco
- Como Formatar Strings — tecnicas de formatacao em Rust
- Rust para Desenvolvimento Web — panorama do ecossistema web em Rust
- Construindo uma API REST com Axum — tutorial complementar sobre Axum