Adding a New Frontend Page
This skill guides you through adding a new page to the Lightfriend Yew WebAssembly frontend.
Overview
A complete page includes:
- Page component in
frontend/src/pages/
- Route enum variant in
main.rs
- Route handler in switch function
- Navigation link (if applicable)
Step-by-Step Process
1. Create Page Component
Create frontend/src/pages/{page_name}.rs:
rust
1use yew::prelude::*;
2use gloo_net::http::Request;
3use crate::config;
4
5#[function_component(PageName)]
6pub fn page_name() -> Html {
7 // State management
8 let data = use_state(|| None::<SomeData>);
9 let loading = use_state(|| true);
10 let error = use_state(|| None::<String>);
11
12 // Load data on mount
13 {
14 let data = data.clone();
15 let loading = loading.clone();
16 let error = error.clone();
17
18 use_effect_with((), move |_| {
19 wasm_bindgen_futures::spawn_local(async move {
20 match fetch_data().await {
21 Ok(result) => {
22 data.set(Some(result));
23 loading.set(false);
24 }
25 Err(e) => {
26 error.set(Some(e.to_string()));
27 loading.set(false);
28 }
29 }
30 });
31 });
32 }
33
34 html! {
35 <div class="page-container">
36 <h1>{"Page Title"}</h1>
37
38 if *loading {
39 <p>{"Loading..."}</p>
40 } else if let Some(err) = (*error).clone() {
41 <p class="error">{err}</p>
42 } else if let Some(content) = (*data).clone() {
43 // Render content
44 <div>
45 {format!("Content: {:?}", content)}
46 </div>
47 }
48 </div>
49 }
50}
51
52// Helper functions
53async fn fetch_data() -> Result<SomeData, Box<dyn std::error::Error>> {
54 let token = /* get from context or local storage */;
55 let backend_url = config::get_backend_url();
56
57 let response = Request::get(&format!("{}/api/endpoint", backend_url))
58 .header("Authorization", &format!("Bearer {}", token))
59 .send()
60 .await?
61 .json::<SomeData>()
62 .await?;
63
64 Ok(response)
65}
66
67#[derive(Clone, serde::Deserialize, serde::Serialize)]
68struct SomeData {
69 // Define your data structure
70}
2. Add Module Declaration
CRITICAL: This codebase does NOT use mod.rs files!
Instead, add the module declaration to the inline mod pages { } block in frontend/src/main.rs:
rust
1mod pages {
2 pub mod home;
3 pub mod landing;
4 pub mod {page_name}; // Add your new page here
5 // ... other pages
6}
NEVER create a mod.rs file - this is a common mistake. Lightfriend uses named module files (e.g., home.rs, landing.rs) and declares them in the inline module block in main.rs.
3. Add Route Variant
In frontend/src/main.rs, add a route variant to the Route enum:
rust
1#[derive(Clone, Routable, PartialEq)]
2pub enum Route {
3 #[at("/")]
4 Home,
5 #[at("/page-name")]
6 PageName,
7 // ... other routes
8 #[not_found]
9 #[at("/404")]
10 NotFound,
11}
4. Add Route Handler
In the switch() function in frontend/src/main.rs, add:
rust
1fn switch(route: Route) -> Html {
2 match route {
3 Route::Home => html! { <Home /> },
4 Route::PageName => html! { <PageName /> },
5 // ... other routes
6 Route::NotFound => html! { <h1>{"404 - Page Not Found"}</h1> },
7 }
8}
5. Add Navigation Link (Optional)
If the page should appear in navigation, add to the Nav component in frontend/src/main.rs:
rust
1#[function_component(Nav)]
2fn nav() -> Html {
3 html! {
4 <nav>
5 <Link<Route> to={Route::Home}>{"Home"}</Link<Route>>
6 <Link<Route> to={Route::PageName}>{"Page Name"}</Link<Route>>
7 // ... other links
8 </nav>
9 }
10}
6. Test the Page
bash
1cd frontend && trunk serve
Navigate to http://localhost:8080/page-name
Common Patterns
Protected Routes (Require Auth)
rust
1use yew_hooks::use_local_storage;
2
3#[function_component(ProtectedPage)]
4pub fn protected_page() -> Html {
5 let token = use_local_storage::<String>("token".to_string());
6
7 if token.is_none() {
8 // Redirect to login
9 let navigator = use_navigator().unwrap();
10 navigator.push(&Route::Login);
11 return html! {};
12 }
13
14 html! {
15 <div>{"Protected content"}</div>
16 }
17}
Page with Form
rust
1use web_sys::HtmlInputElement;
2
3#[function_component(FormPage)]
4pub fn form_page() -> Html {
5 let name_ref = use_node_ref();
6 let email_ref = use_node_ref();
7 let submitting = use_state(|| false);
8
9 let on_submit = {
10 let name_ref = name_ref.clone();
11 let email_ref = email_ref.clone();
12 let submitting = submitting.clone();
13
14 Callback::from(move |e: SubmitEvent| {
15 e.prevent_default();
16 let submitting = submitting.clone();
17
18 let name = name_ref.cast::<HtmlInputElement>()
19 .unwrap()
20 .value();
21 let email = email_ref.cast::<HtmlInputElement>()
22 .unwrap()
23 .value();
24
25 submitting.set(true);
26
27 wasm_bindgen_futures::spawn_local(async move {
28 match submit_form(name, email).await {
29 Ok(_) => {
30 // Handle success
31 }
32 Err(e) => {
33 // Handle error
34 }
35 }
36 submitting.set(false);
37 });
38 })
39 };
40
41 html! {
42 <form onsubmit={on_submit}>
43 <input
44 ref={name_ref}
45 type="text"
46 placeholder="Name"
47 required=true
48 />
49 <input
50 ref={email_ref}
51 type="email"
52 placeholder="Email"
53 required=true
54 />
55 <button type="submit" disabled={*submitting}>
56 if *submitting {
57 {"Submitting..."}
58 } else {
59 {"Submit"}
60 }
61 </button>
62 </form>
63 }
64}
65
66async fn submit_form(name: String, email: String) -> Result<(), Box<dyn std::error::Error>> {
67 let token = /* get from context */;
68
69 Request::post(&format!("{}/api/submit", config::get_backend_url()))
70 .header("Authorization", &format!("Bearer {}", token))
71 .json(&serde_json::json!({
72 "name": name,
73 "email": email,
74 }))?
75 .send()
76 .await?;
77
78 Ok(())
79}
Page with Context
rust
1use yew::prelude::*;
2
3#[derive(Clone, PartialEq)]
4pub struct UserContext {
5 pub user_id: i32,
6 pub email: String,
7}
8
9#[function_component(ContextPage)]
10pub fn context_page() -> Html {
11 let user_ctx = use_context::<UserContext>()
12 .expect("UserContext not found");
13
14 html! {
15 <div>
16 <p>{format!("User ID: {}", user_ctx.user_id)}</p>
17 <p>{format!("Email: {}", user_ctx.email)}</p>
18 </div>
19 }
20}
Page with Route Parameters
rust
1#[derive(Clone, Routable, PartialEq)]
2pub enum Route {
3 #[at("/users/:id")]
4 UserDetail { id: i32 },
5}
6
7#[derive(Properties, PartialEq)]
8pub struct UserDetailProps {
9 pub id: i32,
10}
11
12#[function_component(UserDetail)]
13pub fn user_detail(props: &UserDetailProps) -> Html {
14 let user_data = use_state(|| None::<User>);
15
16 {
17 let user_data = user_data.clone();
18 let user_id = props.id;
19
20 use_effect_with(user_id, move |_| {
21 wasm_bindgen_futures::spawn_local(async move {
22 if let Ok(user) = fetch_user(user_id).await {
23 user_data.set(Some(user));
24 }
25 });
26 });
27 }
28
29 html! {
30 <div>
31 if let Some(user) = (*user_data).clone() {
32 <h1>{user.name}</h1>
33 }
34 </div>
35 }
36}
37
38// In switch function:
39fn switch(route: Route) -> Html {
40 match route {
41 Route::UserDetail { id } => html! { <UserDetail id={id} /> },
42 // ...
43 }
44}
Page with Multiple API Calls
rust
1#[function_component(DashboardPage)]
2pub fn dashboard_page() -> Html {
3 let stats = use_state(|| None::<Stats>);
4 let activity = use_state(|| None::<Vec<Activity>>);
5 let loading = use_state(|| true);
6
7 {
8 let stats = stats.clone();
9 let activity = activity.clone();
10 let loading = loading.clone();
11
12 use_effect_with((), move |_| {
13 wasm_bindgen_futures::spawn_local(async move {
14 // Fetch multiple endpoints in parallel
15 let (stats_result, activity_result) = tokio::join!(
16 fetch_stats(),
17 fetch_activity()
18 );
19
20 if let Ok(s) = stats_result {
21 stats.set(Some(s));
22 }
23 if let Ok(a) = activity_result {
24 activity.set(Some(a));
25 }
26
27 loading.set(false);
28 });
29 });
30 }
31
32 html! {
33 <div>
34 if *loading {
35 <p>{"Loading dashboard..."}</p>
36 } else {
37 <div>
38 {render_stats(&stats)}
39 {render_activity(&activity)}
40 </div>
41 }
42 </div>
43 }
44}
Styling
Lightfriend uses CSS style blocks within the html! macro, NOT inline Tailwind classes.
Common pattern:
rust
1html! {
2 <div class="page-container">
3 <h1 class="page-title">{"Title"}</h1>
4 <div class="content-grid">
5 <div class="card">
6 {"Card content"}
7 </div>
8 </div>
9
10 <style>
11 {r#"
12 .page-container {
13 max-width: 1200px;
14 margin: 0 auto;
15 padding: 2rem;
16 }
17 .page-title {
18 font-size: 2rem;
19 font-weight: bold;
20 margin-bottom: 1rem;
21 }
22 .content-grid {
23 display: grid;
24 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
25 gap: 1rem;
26 }
27 .card {
28 background: white;
29 border-radius: 8px;
30 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
31 padding: 1rem;
32 }
33 "#}
34 </style>
35 </div>
36}
Testing Checklist
File Reference
- Page components:
frontend/src/pages/{page}.rs
- Routes:
frontend/src/main.rs (Route enum + switch function)
- Navigation:
frontend/src/main.rs (Nav component)
- Shared components:
frontend/src/components/
- Backend config:
frontend/src/config.rs