Full-Stack Web App
Build a full-stack web application with a Vite/React frontend and Express 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/node-typescript/web-app
In this guide, we'll build a complete web application with a modern React frontend (using Vite, TypeScript, and shadcn/ui) and an Express backend. The app displays an animated shader background with "WendyOS" text and includes an interactive feature to fetch and display random car data from an API.
This demonstrates how to:
- Serve a production-built React frontend from a Node.js backend
- Create API endpoints that the frontend can consume
- 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
- A WendyOS device plugged in over USB or connectable over Wi-Fi
Project Structure
web-app/
├── Dockerfile
├── wendy.json
├── frontend/ # Vite + React + TypeScript + shadcn/ui
│ ├── package.json
│ ├── src/
│ │ ├── App.tsx
│ │ └── components/
│ └── ...
└── server/ # Express backend
├── package.json
├── tsconfig.json
└── src/
└── index.tsSetting Up Your Project
Initialize the Project
Start from the Wendy full-stack TypeScript template:
wendy init web-app --target wendyos --language node --template fullstack --var APP_ID=web-app --var PORT=8000 --assistant skip --git-init no
cd web-appThe template creates wendy.json, the Dockerfile, frontend files, and TypeScript backend files. The sections below explain how the generated full-stack app fits together.
Run on WendyOS
wendy runWendy will build the app, ask you to select a device if one is not already configured, deploy the app, and print the URL or run output.
Code Breakdown
Generated Frontend
The template includes a Vite React TypeScript frontend:
cd frontend
npm create vite@latest . -- --template react-ts
npm install
npm install -D tailwindcss @tailwindcss/viteInitialize shadcn/ui:
npx shadcn@latest init --defaults
npx shadcn@latest add button tableBuild the frontend:
npm run build
cd ..Generated Express Backend
The generated server/package.json describes the backend:
{
"name": "web-app-server",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"express": "^4"
},
"devDependencies": {
"@types/express": "^5",
"@types/node": "^22",
"typescript": "^5.7",
"tsx": "^4"
},
"engines": {
"node": ">=22"
}
}The generated server/tsconfig.json configures TypeScript:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}The generated server/src/index.ts handles the API:
import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const hostname = process.env.WENDY_HOSTNAME || "0.0.0.0";
const port = 5002;
const CAR_NAMES = ["Honda", "Toyota", "Ford", "Chevrolet", "BMW", "Mercedes", "Audi", "Tesla", "Nissan", "Mazda"];
const CAR_MAKES = ["Civic", "Camry", "Mustang", "Corvette", "M3", "C-Class", "A4", "Model 3", "Altima", "MX-5"];
function randomCar() {
return {
name: CAR_NAMES[Math.floor(Math.random() * CAR_NAMES.length)],
make: CAR_MAKES[Math.floor(Math.random() * CAR_MAKES.length)],
year: Math.floor(Math.random() * (2025 - 1990)) + 1990,
color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0").toUpperCase()}`,
createdAt: new Date().toISOString(),
};
}
// Determine frontend dist path
const frontendDist =
process.env.FRONTEND_DIST ||
(fs.existsSync("/app/frontend/dist") ? "/app/frontend/dist" : path.join(__dirname, "../../frontend/dist"));
console.log(`Serving frontend from: ${frontendDist}`);
// API routes
app.get("/api/random-car", (_req, res) => {
res.json(randomCar());
});
// Static files
app.use(express.static(frontendDist));
// Fallback to index.html for SPA routing
app.get("*", (_req, res) => {
res.sendFile(path.join(frontendDist, "index.html"));
});
// Bind to 0.0.0.0 to accept connections from all interfaces (required for container networking)
app.listen(port, "0.0.0.0", () => {
console.log(`Server running on http://${hostname}:${port}`);
});Install dependencies and build:
cd server
npm install
npm run build
cd ..API Endpoint: The /api/random-car endpoint returns a randomly generated car object with name, make, year, color, and timestamp. The frontend fetches this data and displays the last 10 cars in a table.
Generated Dockerfile
The generated project includes a Dockerfile:
# Build frontend
FROM node:22-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Build server
FROM node:22-slim AS server-builder
WORKDIR /app
COPY server/package*.json ./
RUN npm install
COPY server/tsconfig.json ./
COPY server/src ./src
RUN npm run build
# Runtime stage
FROM node:22-slim
WORKDIR /app
COPY --from=server-builder /app/dist ./dist
COPY --from=server-builder /app/node_modules ./node_modules
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
ENV FRONTEND_DIST=/app/frontend/dist
EXPOSE 5002
CMD ["node", "dist/index.js"]Generated wendy.json
Review the generated wendy.json file:
{
"appId": "com.example.node-web-app",
"version": "0.0.1",
"entitlements": [
{ "type": "network" }
],
"readiness": {
"tcpSocket": { "port": 5002 },
"timeoutSeconds": 30
},
"hooks": {
"postStart": {
"cli": "wendy utils open-browser http://${WENDY_HOSTNAME}:5002"
}
}
}The readiness probe tells the CLI to wait until port 5002 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:5002Replace the hostname: Each WendyOS device has a unique hostname. Replace wendyos-true-probe with your device's actual hostname shown in the CLI output.
Test the API Directly
curl http://wendyos-true-probe.local:5002/api/random-carResponse:
{"name":"Toyota","make":"Camry","year":2015,"color":"#A4F2C1","createdAt":"2024-01-15T10:30:00.000Z"}Learn More
Next Steps
Now that you have a full-stack web app running:
- Add more API endpoints for CRUD operations
- Implement real-time updates with Socket.io
- Connect to WendyOS device sensors and display data in the UI
- Add authentication middleware
- Use a database for persistent storage