Part 2: Your First Feature - the Poll Index
Create the polls app, add the first migration, expose get_questions, and render the index page.
Part 2: Your First Feature - the Poll Index
In this part you will build the first vertical slice: a polls app with Question and Choice models, a first migration, a get_questions server function, and a WASM index page that lists polls.
Ownership and authentication come later. That is deliberate. The first migration creates polls without an author_id column; Part 5 adds ownership with a second migration.
Create the Polls App
Generate a pages app:
reinhardt-admin startapp polls --template pagesThe app should be registered in src/config/apps.rs:
use reinhardt::installed_apps;
installed_apps! {
polls: "polls",
}The completed example also registers users, but do not add it until Part 4.
Add the Initial Models
Open src/apps/polls/models.rs and add the first version of the poll models:
use chrono::{DateTime, Utc};
use reinhardt::db::associations::ForeignKeyField;
use reinhardt::prelude::*;
use serde::{Deserialize, Serialize};
#[model(app_label = "polls", table_name = "questions")]
#[derive(Serialize, Deserialize)]
pub struct Question {
#[field(primary_key = true)]
pub id: i64,
#[field(max_length = 200)]
pub question_text: String,
#[field(auto_now_add = true)]
pub pub_date: DateTime<Utc>,
}
#[model(app_label = "polls", table_name = "choices")]
#[derive(Serialize, Deserialize)]
pub struct Choice {
#[field(primary_key = true)]
pub id: i64,
#[rel(foreign_key, related_name = "choices")]
pub question: ForeignKeyField<Question>,
#[field(max_length = 200)]
pub choice_text: String,
#[field(default = 0)]
pub votes: i32,
}This is the first-slice version. In the completed example, Question also has an author foreign key to User; Part 5 adds that field and migration.
Generate and Apply Migration 0001
Create the first migration:
cargo make makemigrations
cargo make migrateThe generated migration should create questions with id, pub_date, and question_text, but no author_id. The reference migration's questions table contains only these columns:
Operation::CreateTable {
name: "questions".to_string(),
columns: vec![
ColumnDefinition {
name: "id".to_string(),
type_definition: FieldType::BigInteger,
not_null: true,
unique: false,
primary_key: true,
auto_increment: true,
default: None,
},
ColumnDefinition {
name: "pub_date".to_string(),
type_definition: FieldType::TimestampTz,
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
ColumnDefinition {
name: "question_text".to_string(),
type_definition: FieldType::VarChar(200u32),
not_null: true,
unique: false,
primary_key: false,
auto_increment: false,
default: None,
},
],
constraints: vec![],
without_rowid: None,
interleave_in_parent: None,
partition: None,
}If author_id appears in 0001_initial.rs, you have accidentally skipped ahead to Part 5.
Re-export the Shared Info Types
The #[model] macro generates model-info companion types that are safe to send to the browser. Re-export them from src/shared/types.rs:
pub use crate::apps::polls::models::{ChoiceInfo, QuestionInfo};This keeps the server function return type and the WASM component type identical.
Add the Server Function
Create src/apps/polls/server_fn.rs and expose a query for the index page:
use crate::shared::types::QuestionInfo;
use reinhardt::pages::server_fn::{ServerFnError, server_fn};
#[server_fn]
pub async fn get_questions(
#[inject] _db: reinhardt::DatabaseConnection,
) -> std::result::Result<Vec<QuestionInfo>, ServerFnError> {
use crate::apps::polls::models::Question;
use reinhardt::Model;
let manager = Question::objects();
let questions = manager
.all()
.all()
.await
.map_err(|e| ServerFnError::application(e.to_string()))?;
let latest: Vec<QuestionInfo> = questions
.into_iter()
.take(5)
.map(QuestionInfo::from)
.collect();
Ok(latest)
}The current reference implementation takes five rows from the manager query. Do not rely on a specific ordering until you add one explicitly.
Split Server and Client Routes
The app-level src/apps/polls/urls.rs exposes separate router builders for the two targets:
#[cfg(server)]
pub mod server_urls;
#[cfg(client)]
pub mod client_router;
#[cfg(server)]
pub fn server_url_patterns() -> reinhardt::ServerRouter {
server_urls::server_url_patterns()
}
#[cfg(client)]
pub fn client_url_patterns() -> reinhardt::ClientRouter {
client_router::client_url_patterns()
}Register the server function in src/apps/polls/urls/server_urls.rs:
use crate::apps::polls::server_fn::get_questions;
use reinhardt::ServerRouter;
use reinhardt::pages::server_fn::ServerFnRouterExt;
pub fn server_url_patterns() -> ServerRouter {
ServerRouter::new().server_fn(get_questions::marker)
}Register the index client route in src/apps/polls/urls/client_router.rs:
use crate::client::pages::index_page;
use reinhardt::ClientRouter;
pub fn client_url_patterns() -> ClientRouter {
ClientRouter::new().route("index", "/", index_page)
}At the project level, aggregate app routers in src/config/urls.rs. Do not list individual poll server functions here:
#[routes]
pub fn routes() -> UnifiedRouter {
let router = UnifiedRouter::new();
#[cfg(server)]
let router = router.server(|s| {
s.mount("/", crate::apps::polls::urls::server_url_patterns())
});
#[cfg(client)]
let router = router.mount_unified(
"/",
UnifiedRouter::new().client(|_| crate::apps::polls::urls::client_url_patterns()),
);
router
}Render the Index Page
The client entry point from Part 1 loads routes from inventory:
ClientLauncher::new("#root")
.register_routes_from_inventory()
.launch()The page aggregator maps the named route to the polls component:
pub fn index_page() -> Page {
with_nav(crate::apps::polls::client::components::polls_index())
}The index component uses the server function as an async resource:
pub fn polls_index() -> Page {
let load_questions = use_resource(
|| async move { get_questions().await.map_err(|e| e.to_string()) },
(),
);
page!(|load_questions: Resource<Vec<QuestionInfo>, String>| {
div {
class: "max-w-4xl mx-auto px-4 mt-12",
h1 { "Polls" }
{
match load_questions.get() {
ResourceState::Loading => page!(|| {
p { "Loading..." }
})(),
ResourceState::Success(questions) if questions.is_empty() => page!(|| {
p {
class: "text-muted",
"No polls are available."
}
})(),
ResourceState::Success(questions) => page!(|questions: Vec<QuestionInfo>| {
div {
class: "space-y-2",
for question in questions {
a {
href: polls_routes::reverse("detail", &[("question_id", question.id.to_string().as_str())]),
class: "block p-4 border border-border rounded-lg bg-surface-primary hover:bg-surface-secondary transition-colors",
{ question.question_text.clone() }
}
}
}
})(questions),
ResourceState::Error(error) => page!(|error: String| {
div {
class: "alert-danger",
{ error }
}
})(error),
}
}
}
})(load_questions)
}The final example adds a "Create new poll" button and owner-only controls. Leave those out for now; they need authentication and ownership from Parts 4 and 5.
Seed a Poll
Until the admin arrives in Part 6, the quickest local seed is SQL. Use the same database that cargo make dev points at:
with inserted_question as (
insert into questions (question_text, pub_date)
values ('What should we build next?', now())
returning id
)
insert into choices (question_id, choice_text, votes)
select id, 'More tutorials', 0 from inserted_question
union all
select id, 'More examples', 0 from inserted_question;If your database does not support now() or data-modifying common table expressions, use the timestamp and inserted-ID syntax it expects.
Checkpoint
Run the app:
cargo make devOpen http://127.0.0.1:8000/. You should see the poll list rendered by the WASM client. Clicking a poll may route to a not-found or unfinished page until Part 3 adds the detail route.
Before continuing:
migrations/polls/0001_initial.rshas noauthor_id.get_questionsreturnsVec<QuestionInfo>.src/config/urls.rsaggregatespolls::urls::server_url_patterns()andpolls::urls::client_url_patterns().- The browser renders the poll index through
ClientLauncher.