A self-hosted dashboard that outputs pure HTML and CSS. No JavaScript. No framework. No runtime.
Slate fetches data from your services at build time and renders a static HTML file. The result works on anything with an HTML renderer — standard browsers, e-ink displays, screen readers, kiosk screens, curl.
- You define your dashboard layout and widgets in YAML
- Slate's Python build step fetches data from your services (weather, tasks, bookmarks, etc.)
- Everything is rendered into a single static HTML+CSS file
- Serve it from anywhere — any web server, a CDN, a NAS, a Raspberry Pi
API keys stay on the server. They're used at build time, never shipped to the browser. Rebuild on a cron to keep data fresh.
Every other self-hosted dashboard is a client-side application. Your browser loads a JavaScript bundle, makes API calls to your services, and renders the results. That means:
- Your API keys are in the browser
- Every page load triggers a cascade of network requests
- You need CORS proxies or middleware for most integrations
- Nothing renders without a JavaScript runtime
Slate takes a fundamentally different approach. All data fetching happens server-side at build time. The output is flat HTML and CSS — no JavaScript required. This makes it:
- Secure — API keys never leave the server
- Fast — no client-side fetching, no loading spinners, instant render
- Universal — works on any device that can render HTML
- Simple — no runtime, no proxy, no CORS configuration
- Resilient — the last successful build always works, even if a service is down
git clone https://github.com/pwelty/slate.git
cd slate
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp config/dashboard-example.yaml config/dashboard.yaml
# Edit config/dashboard.yaml with your services and API keys
python3 src/scripts/dashboard_renderer.py
python3 src/scripts/serve.py
# Open http://localhost:5173Your dashboard is defined in config/dashboard.yaml:
dashboard:
title: "My dashboard"
theme: "paper"
components:
- id: "weather"
widget: "weather"
position: { row: 1, column: 1, width: 4, height: 2 }
config:
location: "30033"
apiKey: "YOUR_OPENWEATHER_KEY"
units: "fahrenheit"
- id: "tasks"
widget: "todoist"
position: { row: 1, column: 5, width: 4, height: 2 }
config:
apiToken: "YOUR_TODOIST_TOKEN"
limit: 8| Widget | Description |
|---|---|
| weather | Current conditions via OpenWeatherMap |
| forecast | Multi-day weather forecast |
| radar | Weather radar map |
| todoist | Tasks with priority indicators |
| trilium | Recent notes from Trilium |
| linkwarden | Bookmarks from Linkwarden |
| obsidian | Notes via Obsidian Local REST API |
| pihole | Pi-hole ad blocking stats (v5 and v6+) |
| rss | RSS feed aggregation |
| clock | Date and time (rendered at build) |
| link | Service shortcuts |
| text | Custom content |
| image | Image display |
| status-summary | System health overview |
Themes are YAML files that define colors, typography, and spacing. Pick one in your config:
| Theme | Description |
|---|---|
| paper | Clean, classic, document-like |
| dark | Professional dark |
| light | Minimal light |
| ocean | Deep blue |
| tokyo-night | Developer favorite |
| synthwave | 80s neon |
| retro | Terminal aesthetic |
| minimal-dark | Stripped-down dark |
Create your own by copying any theme YAML and editing the values.
# Auto-rebuild on file changes
python3 scripts/auto-rebuild.py
# Manual build
python3 src/scripts/dashboard_renderer.py
# Serve locally
python3 src/scripts/serve.pyThe build output is a static dist/ directory. Serve it however you want:
# Simple Python server
cd dist && python3 -m http.server 8080
# Nginx, Apache, Caddy — just point at dist/
# Docker
docker-compose up
# Tailscale for secure remote access
tailscale serve / http://localhost:5173
# Access at https://slate.yourname.ts.netRebuild on a schedule (cron, systemd timer, etc.) to keep widget data fresh.
Widgets are YAML files in src/widgets/. Each defines a schema, optional data processing (Python), HTML template, and CSS:
extends: "widget"
metadata:
type: "api"
description: "My custom widget"
schema:
apiKey:
type: "string"
required: true
dataProcessing:
generateData: |
import requests
response = requests.get("https://api.example.com",
headers={"Authorization": config['apiKey']})
result = response.json()
widget-body: |
<div class="my-widget">{{ result.data }}</div>
css: |
.my-widget { color: var(--color-text); }See widget definitions for the full specification.
Slate is one of many self-hosted dashboard projects. These are excellent and may be a better fit depending on your needs:
- Dashy — highly customizable, 50+ widgets, extensive theming
- Homepage — modern design, 100+ service integrations
- Homarr — GUI configuration, user management
- Heimdall — simple application launcher
- Homer — minimal static homepage
Slate's approach is different: server-side data fetching with pure HTML+CSS output, no JavaScript runtime required.
Contributions are welcome — bug reports, widgets, themes, documentation.
MIT License © 2025 Paul Welty
