Full-Stack Web App
Build a full-stack device dashboard with a React frontend and Axum backend on WendyOS
Building a Full-Stack Web App
Source Code: The complete source code for this example is available at github.com/wendylabsinc/samples/rust/web-app
The Wendy full-stack template scaffolds a complete device dashboard: a modern React frontend (Vite, TypeScript, Tailwind v4, shadcn/ui) talking to an Axum backend that exposes your device's hardware over HTTP. The whole stack ships as a single container — the backend serves the built frontend and the API from one binary.
This demonstrates how to:
- Serve a production-built React single-page app from a Rust backend
- Expose device features (camera, audio, GPU, system info) and persistent data through
/api/*routes - Deploy the entire stack as a single container to your WendyOS device
Prerequisites
- Wendy CLI installed on your development machine
- Node.js 22+ and npm installed (for building the frontend)
- Rust 1.85 or later installed (via
rustup) - A WendyOS device plugged in over USB or connectable over Wi-Fi
Setting Up Your Project
Initialize the Project
Start from the Wendy full-stack Rust template:
wendy init web-app --target wendyos --language rust --template fullstack --var APP_ID=web-app --var PORT=8000 --assistant skip --git-init no
cd web-app

The template generates the entire project — wendy.json, the Dockerfile, the Rust backend, and the full React frontend. You don't scaffold the frontend by hand. The sections below explain how the generated full-stack app fits together.
Run on WendyOS
wendy run

Wendy will build the frontend and backend, ask you to select a device if one is not already configured, deploy the app, and open your browser to the dashboard.
Project Structure
The generated project has a Rust backend at the root and the React frontend under frontend/:
web-app/
├── Cargo.toml # Rust backend dependencies
├── Dockerfile # multi-stage: build frontend, build backend, runtime
├── wendy.json # entitlements + readiness + postStart hook
├── src/
│ └── main.rs # Axum backend: /api/* routes + static SPA
└── frontend/ # React 19 + Vite + Tailwind v4 + shadcn/ui
├── package.json
├── vite.config.ts
├── index.html
└── src/
├── App.tsx # sidebar dashboard + react-router routes
├── main.tsx
├── components/ # app-sidebar, site-header, shadcn/ui, ...
└── pages/ # camera, audio, persistence, gpu, systemThe frontend builds to frontend/dist, which the Dockerfile copies into the backend image as ./static.
Code Breakdown
Generated Frontend
The frontend is a React 19 single-page app built with Vite, Tailwind v4, and shadcn/ui. App.tsx sets up a sidebar dashboard and uses react-router-dom v7 for client-side routing across five pages — camera, audio, persistence, gpu, system — with the default route redirecting to /camera:
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import CameraPage from "@/pages/camera"
import AudioPage from "@/pages/audio"
import PersistencePage from "@/pages/persistence"
import GpuPage from "@/pages/gpu"
import SystemPage from "@/pages/system"
export default function App() {
return (
<BrowserRouter>
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<Routes>
<Route path="/" element={<Navigate to="/camera" replace />} />
<Route path="/camera" element={<CameraPage />} />
<Route path="/audio" element={<AudioPage />} />
<Route path="/persistence" element={<PersistencePage />} />
<Route path="/gpu" element={<GpuPage />} />
<Route path="/system" element={<SystemPage />} />
</Routes>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</BrowserRouter>
)
}Each page under frontend/src/pages/ calls the matching backend /api/* route — for example, the camera page opens a WebSocket to /api/camera/stream, and the persistence page does CRUD against /api/cars. To work on the UI you only need npm install and npm run build (or npm run dev) inside frontend/; wendy run does the build for you.
Generated Axum Backend
The generated Cargo.toml describes the backend. It pulls in Axum (with WebSocket support), GStreamer for camera/audio capture, and rusqlite (bundled SQLite) for persistence:
[package]
name = "web-app"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8.8", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
gstreamer = "0.23"
gstreamer-app = "0.23"
futures-util = "0.3"The main() function in src/main.rs wires up the /api/* routes, then falls back to serving the built SPA from ./static (with index.html as the SPA fallback for client-side routing):
#[tokio::main]
async fn main() {
gstreamer::init().expect("Failed to initialise GStreamer");
let hostname = std::env::var("WENDY_HOSTNAME").unwrap_or_else(|_| "unknown".to_string());
let (cam_tx, _) = broadcast::channel::<Vec<u8>>(16);
let (aud_tx, _) = broadcast::channel::<Vec<u8>>(16);
let state = AppState {
db: Arc::new(Mutex::new(init_db())),
camera: Arc::new(CameraSingleton(Mutex::new(GstCapture {
pipeline: None,
current_device: None,
tx: cam_tx,
client_count: 0,
}))),
audio: Arc::new(AudioSingleton(Mutex::new(GstCapture {
pipeline: None,
current_device: None,
tx: aud_tx,
client_count: 0,
}))),
};
let api_routes = Router::new()
.route("/api/cars", get(list_cars).post(create_car))
.route(
"/api/cars/{id}",
get(get_car).put(update_car).delete(delete_car),
)
.route("/api/cameras", get(get_cameras))
.route("/api/microphones", get(get_microphones))
.route("/api/speakers", get(get_speakers))
.route("/api/gpu", get(gpu_info))
.route("/api/system", get(system_info))
.route("/api/camera/stream", get(camera_ws_handler))
.route("/api/audio/stream", get(audio_ws_handler))
.with_state(state);
let serve_dir = ServeDir::new("./static").fallback(ServeFile::new("./static/index.html"));
let app = api_routes.fallback_service(serve_dir);
let addr = "0.0.0.0:8000";
eprintln!("Starting server on {addr} (hostname: {hostname})");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}API surface: The backend exposes routes for system (host/CPU/memory/disk/uptime), gpu, camera and audio (MJPEG/PCM streamed over WebSockets via GStreamer), and data — a cars CRUD backed by SQLite. The database is created at /data/cars.db, which is mounted by the persist entitlement so the data survives restarts.
Generated Dockerfile
The generated Dockerfile is multi-stage: build the frontend with Node, build the Rust backend (with GStreamer and SQLite dev headers), then assemble a slim runtime image with the GStreamer plugins, ALSA, v4l, and SQLite tooling needed at runtime:
# Stage 1: Build frontend
FROM node:22-slim AS frontend
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Build Rust backend
FROM rust:1.85-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgstreamer-plugins-bad1.0-dev \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml ./
# Create a dummy main.rs so dependencies can be cached
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release || true
# Now copy the real source and build
COPY src ./src
RUN touch src/main.rs && cargo build --release
# Stage 3: Final image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
gstreamer1.0-tools \
gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
gstreamer1.0-libav \
gstreamer1.0-alsa \
libgstreamer1.0-0 \
libgstreamer-plugins-base1.0-0 \
alsa-utils \
v4l-utils \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/web-app /usr/local/bin/web-app
COPY --from=frontend /frontend/dist ./static
EXPOSE 8000
CMD ["web-app"]Generated wendy.json
The generated wendy.json grants the entitlements the dashboard needs — network in host mode, plus camera, audio, gpu, and a persist volume (web-app-data) mounted at /data for the SQLite database. The readiness probe and postStart hook target port 8000:
{
"appId": "web-app",
"platform": "linux",
"version": "0.1.0",
"entitlements": [
{
"type": "network",
"mode": "host"
},
{
"type": "camera"
},
{
"type": "audio"
},
{
"type": "gpu"
},
{
"type": "persist",
"name": "web-app-data",
"path": "/data"
}
],
"readiness": {
"tcpSocket": { "port": 8000 },
"timeoutSeconds": 30
},
"hooks": {
"postStart": {
"cli": "wendy utils open-browser http://${WENDY_HOSTNAME}:8000"
}
}
}The readiness probe tells the CLI to wait until port 8000 is accepting connections before proceeding. The postStart hook automatically opens your browser once the app is ready.
Run Again on WendyOS
Deploy your full-stack web application to your WendyOS device:
wendy runTest Your Web App
Your browser will open automatically once the app is ready, thanks to the postStart hook. If it doesn't, navigate to:
http://wendyos-true-probe.local:8000Replace the hostname: Each WendyOS device has a unique hostname. Replace wendyos-true-probe with your device's actual hostname shown in the CLI output.
You'll land on the camera page; use the sidebar to switch between the camera, audio, persistence, GPU, and system pages.
Test the API Directly
Query the system info endpoint:
curl http://wendyos-true-probe.local:8000/api/systemAdd a car to the persistent SQLite store:
curl -X POST http://wendyos-true-probe.local:8000/api/cars \
-H "Content-Type: application/json" \
-d '{"make":"Toyota","model":"Camry","color":"#1E88E5","year":2015}'List the saved cars:
curl http://wendyos-true-probe.local:8000/api/carsResponse:
[{"id":1,"make":"Toyota","model":"Camry","color":"#1E88E5","year":2015,"created_at":"2026-06-06 10:30:00","updated_at":null}]Learn More
Next Steps
Now that you have a full-stack web app running:
- Add more
/api/*endpoints and a matching page underfrontend/src/pages/ - Implement real-time updates with WebSockets (the camera and audio pages already do)
- Connect to more WendyOS device sensors and display data in the UI
- Add authentication middleware
- Extend the SQLite schema for richer persistent storage