When we launched the first version of 0cron.dev, the "frontend" was a single static HTML file. A marketing page with some nice gradients, a pricing table, and a call-to-action button that pointed to an API that did not yet have a UI. The backend was solid -- 14 endpoints, 8 database tables, 2,852 lines of Rust -- but users had no way to interact with it beyond raw HTTP requests.
That changed in a single session. On March 11, 2026, we converted 0cron-website from static HTML into a full SvelteKit 2 application with 13 route pages, a reactive auth store, an API client, a dark sidebar layout, and a job creation wizard with a cPanel-style cron expression builder. This is the story of how we did it, and the architectural decisions that made it possible.
Why SvelteKit, Why Now
We had a working Rust API. We needed a dashboard. The question was never "should we build a frontend?" but "what framework gives us the most productivity for a two-person team where one person is an AI?"
SvelteKit 2 with Svelte 5 runes was the obvious choice. Here is why:
File-based routing eliminates boilerplate. Every page is a file. Every layout wraps its children automatically. There is no router configuration, no route registration, no middleware chain to set up manually. You create a file, and it becomes a route.
Svelte 5 runes replace the store pattern. Instead of importing writable stores, subscribing, and managing teardown, you write $state and the compiler handles reactivity. For an auth store that needs to persist to localStorage, this is dramatically simpler than the Svelte 4 approach.
TailwindCSS 4 ships with SvelteKit out of the box. No PostCSS configuration, no purge setup, no build pipeline tweaks. Install, import, write classes.
Bun as the runtime gave us fast installs and fast dev server startup. When you are iterating on 13 pages in a single session, sub-second HMR matters.
The Route Structure
The first architectural decision was route groups. SvelteKit supports parenthesized directory names that create layout boundaries without affecting the URL. We used two groups: (auth) for the login flow and (app) for the authenticated dashboard.
src/routes/
+page.svelte -- Landing (loads static/landing.html)
(auth)/
login/+page.svelte -- Google Sign-In + email/password
(app)/
+layout.svelte -- Auth guard + sidebar layout
dashboard/+page.svelte -- Stats cards, upcoming jobs, recent executions
jobs/+page.svelte -- Job list with actions (trigger/pause/delete)
jobs/new/+page.svelte -- Job creation wizard (CronBuilder)
jobs/[id]/+page.svelte -- Job detail + execution history + stats
billing/+page.svelte -- Plan display, Stripe Checkout/Portal
settings/+page.svelte -- API keys + secrets management
account/+page.svelte -- ProfileThis separation is not cosmetic. The (app)/+layout.svelte file contains the auth guard -- if you are not authenticated, you get redirected to login. Every page inside (app)/ inherits this protection automatically. The (auth)/ group has its own minimal layout without the sidebar or navigation. And the root +page.svelte loads the static marketing page for unauthenticated visitors.
The beauty of this pattern is that adding a new authenticated page requires zero configuration. Create a file inside (app)/, and it is automatically protected, wrapped in the sidebar layout, and accessible at the corresponding URL path.
The Auth Store: Svelte 5 Runes in Practice
Authentication state is the backbone of any dashboard application. It needs to be accessible everywhere, persist across page refreshes, and synchronize with the API client. In Svelte 4, you would reach for a writable store with a custom subscription. In Svelte 5, you write a class with $state fields.
class AuthStore {
token = $state<string | null>(null);
user = $state<User | null>(null);get isAuthenticated() { return !!this.token; } get isAdmin() { return this.user?.is_admin ?? false; }
login(token: string, user: User) { this.token = token; this.user = user; if (browser) { localStorage.setItem('auth_token', token); localStorage.setItem('auth_user', JSON.stringify(user)); } }
logout() { this.token = null; this.user = null; if (browser) { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_user'); } } } ```
There are several things worth noting about this pattern.
$state makes class fields reactive. When this.token changes, any component that reads authStore.token or authStore.isAuthenticated re-renders automatically. No subscription, no $: reactive declarations, no .subscribe() callbacks to clean up.
Getters become derived state. isAuthenticated and isAdmin are plain JavaScript getters, but because they read $state fields, Svelte tracks them as dependencies. This is the runes equivalent of $derived, but expressed as standard class syntax.
The browser guard is essential. SvelteKit runs code on both server and client. localStorage does not exist on the server. The browser import from $app/environment gates side effects to client-only execution. Without this, the store would crash during SSR.
Initialization happens in the constructor (not shown above for brevity). On mount, the store reads from localStorage and hydrates the token and user. This means a page refresh does not log the user out -- the token persists until explicit logout or expiry.
The API Client: Bearer Token Injection
Every authenticated request needs the JWT token in the Authorization header. Rather than passing the token manually to every fetch call, we built a thin client wrapper.
import { authStore } from '$lib/stores/auth.svelte';class ApiClient { private baseUrl: string;
constructor(baseUrl: string) { this.baseUrl = baseUrl; }
private async request
if (authStore.token) {
headers['Authorization'] = Bearer ${authStore.token};
}
const response = await fetch(${this.baseUrl}${path}, {
...options,
headers,
});
if (response.status === 401) { authStore.logout(); window.location.href = '/login'; throw new Error('Unauthorized'); }
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || Request failed: ${response.status});
}
return response.json(); }
get
export const api = new ApiClient('/api/v1'); ```
The 401 auto-redirect is a small but important detail. If the JWT expires while the user is on the dashboard, the next API call will get a 401. Rather than showing a cryptic error, the client clears the auth state and redirects to the login page. The user re-authenticates and picks up where they left off. No stale state, no confusion.
This pattern also means that every component can simply call api.get('/jobs') or api.post('/jobs', jobData) without worrying about authentication. The token is injected automatically, and expiry is handled globally.
The Job Creation Wizard
The most complex component in the dashboard is the job creation page. A cron job has many parameters: the HTTP method and URL, the cron schedule expression, headers, body, timeout, retry configuration, and notification settings. Presenting all of this in a single form would overwhelm users.
We built a wizard with tabs, and the centerpiece is the CronBuilder -- a cPanel-style cron expression builder that lets users construct schedules visually.
const schedulePresets = [
{ label: 'Every minute', cron: '* * * * *' },
{ label: 'Every 5 minutes', cron: '*/5 * * * *' },
{ label: 'Every 15 minutes', cron: '*/15 * * * *' },
{ label: 'Every 30 minutes', cron: '*/30 * * * *' },
{ label: 'Every hour', cron: '0 * * * *' },
{ label: 'Every 2 hours', cron: '0 */2 * * *' },
{ label: 'Every 6 hours', cron: '0 */6 * * *' },
{ label: 'Every 12 hours', cron: '0 */12 * * *' },
{ label: 'Every day at midnight', cron: '0 0 * * *' },
{ label: 'Every day at 9am', cron: '0 9 * * *' },
{ label: 'Every day at 6pm', cron: '0 18 * * *' },
{ label: 'Every Monday at 9am', cron: '0 9 * * 1' },
{ label: 'Every weekday at 9am', cron: '0 9 * * 1-5' },
{ label: 'Every weekend at 10am', cron: '0 10 * * 0,6' },
{ label: 'First day of month', cron: '0 0 1 * *' },
{ label: 'Last day of month', cron: '0 0 L * *' },
{ label: 'Every quarter', cron: '0 0 1 1,4,7,10 *' },
{ label: 'Every year (Jan 1)', cron: '0 0 1 1 *' },
];Eighteen presets cover the vast majority of real-world scheduling needs. Users who have never written a cron expression can click "Every weekday at 9am" and get 0 9 1-5 without understanding the five-field format. Advanced users can toggle to the manual input and type raw expressions.
The wizard also includes quick-add patterns for common authentication setups. Instead of manually constructing Authorization headers, users pick from patterns like "Bearer Token," "Basic Auth," or "API Key Header," and the wizard fills in the header name and value format. This is the kind of UX detail that separates a developer tool from a developer-hostile tool.
The Sidebar: Dark Theme with Green Accents
The sidebar is the persistent navigation element across all authenticated pages. We chose a dark sidebar (bg-gray-900) with green accent colors (text-green-400, bg-green-500) to match 0cron's brand identity. The structure is straightforward but worth examining for the Svelte 5 patterns it uses.
<script lang="ts">
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import {
LayoutDashboard, Clock, Plus, CreditCard,
Settings, User, Shield, LogOut
} from 'lucide-svelte';const navItems = [ { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/jobs', label: 'Jobs', icon: Clock }, { href: '/jobs/new', label: 'New Job', icon: Plus }, { href: '/billing', label: 'Billing', icon: CreditCard }, { href: '/settings', label: 'Settings', icon: Settings }, { href: '/account', label: 'Account', icon: User }, ];
const adminItems = [ { href: '/admin/users', label: 'Users', icon: Shield }, { href: '/admin/jobs', label: 'All Jobs', icon: Clock }, ];
{#if authStore.isAdmin}
A few architectural points stand out here.
Lucide icons as components. In Session 2, Phase 1, we replaced all inline emoji with Lucide SVG icons across the entire application -- 23 inline SVGs in total. In the sidebar, each icon is a Svelte component (), which means the icon data is included in the bundle only if the page actually uses it. Tree-shaking handles the rest.
Active route detection uses $page.url.pathname.startsWith(). This means /jobs/new highlights both the "Jobs" and "New Job" nav items. For a small dashboard, this is the right trade-off -- users always know which section they are in.
Admin section is conditionally rendered. The {#if authStore.isAdmin} block reads the reactive getter from our auth store. When a non-admin user logs in, the admin section simply does not exist in the DOM. No CSS hiding, no disabled states, no "you don't have permission" tooltips. The section is invisible because it does not render.
The Auth Guard Layout
The (app)/+layout.svelte file is the gatekeeper for all authenticated routes. Its job is simple: check if the user is authenticated, redirect if not, and render the sidebar layout if they are.
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';$effect(() => { if (!authStore.isAuthenticated) { goto('/login'); } });
{#if authStore.isAuthenticated}
The $effect rune watches authStore.isAuthenticated. If the token is cleared (logout, expiry, 401 redirect), the effect fires and navigates to /login. This is reactive -- it does not just check once on mount. If a background API call triggers a logout via the 401 handler in the API client, this effect catches the state change and redirects immediately.
The {#if authStore.isAuthenticated} block prevents a flash of dashboard content before the redirect completes. Without it, you might see the sidebar and page content for a frame before goto('/login') takes effect.
The Landing Page: Static HTML Preserved
One pragmatic decision: we kept the static marketing page. Rather than rewriting the landing page in Svelte (which would have added significant scope), the root +page.svelte loads the existing static/landing.html content. The marketing page was already polished, responsive, and optimized. Rewriting it in Svelte components would have consumed time with zero user benefit.
This is a pattern worth highlighting. Not everything needs to be a Svelte component. Static content that does not need reactivity, does not need data fetching, and does not need interactivity can remain static. SvelteKit's static/ directory serves files directly, and a simple page component can embed or redirect to them.
What We Built in One Session
Let us take inventory. In a single Claude Code session (Phase 4 of Session 2, March 11, 2026), we produced:
- 13 route pages across two layout groups
- A reactive auth store using Svelte 5 runes with localStorage persistence
- An API client with automatic Bearer token injection and 401 handling
- A dark sidebar with Lucide icons and conditional admin section
- A job creation wizard with 18 schedule presets and a cPanel-style cron builder
- A job detail page with execution history and statistics
- A billing page integrating Stripe Checkout and Customer Portal
- A settings page for API keys and encrypted secrets management
- An account page for profile management
All of this with TypeScript throughout, TailwindCSS for styling, and Lucide Svelte for iconography. No UI component library, no CSS framework beyond Tailwind, no state management library beyond what Svelte 5 provides natively.
Lessons Learned
Route groups are underused in SvelteKit. The (auth) / (app) pattern solves the "some pages need a sidebar, some don't" problem cleanly. Most SvelteKit tutorials skip this feature, but for any application with authentication, it is essential.
Svelte 5 runes make classes viable for state management. The old Svelte store pattern (writable, derived, subscribe) worked but felt alien to developers coming from other frameworks. Runes let you write normal classes with normal methods, and the compiler makes them reactive. The auth store is a perfect example -- it reads like plain TypeScript, but every $state field is tracked.
Dark dashboards need careful contrast. With bg-gray-900 sidebar and bg-gray-950 main content, the difference is subtle. The green accents (text-green-400) and border separators (border-gray-800) are what make the sections visually distinct. Without them, the sidebar would bleed into the content area.
Preset-driven UX reduces cognitive load. The 18 schedule presets in the CronBuilder eliminate the need for users to understand cron syntax. For a service that targets developers who are tired of managing their own cron infrastructure, removing friction from job creation is a competitive advantage.
Keep what works. The static landing page was fine. We did not rewrite it in Svelte. We did not add client-side routing to it. We served it as-is. In a single session where every minute counts, knowing what not to build is as important as knowing what to build.
The dashboard gave 0cron a face. The API was always the product, but without a UI, the product was invisible. Converting from static HTML to a full SvelteKit dashboard in a single session was possible because of three things: SvelteKit's file-based routing eliminated configuration overhead, Svelte 5 runes simplified state management, and having a clear API contract meant the frontend was purely about presentation and interaction, not business logic.
The next article explores a feature where the direction reverses -- instead of 0cron calling your endpoints, your jobs call 0cron. That is heartbeat monitoring, and it solves a problem that every ops team faces.
---
This is article 7 of 10 in the "How We Built 0cron" series.
1. Why the World Needs a $2 Cron Job Service 2. 4 Agents, 1 Product: Building 0cron in a Single Session 3. Building a Cron Scheduler Engine in Rust 4. "Every Day at 9am": Natural Language Schedule Parsing 5. Multi-Channel Notifications: Email, Slack, Discord, Telegram, Webhooks 6. Stripe Integration for a $1.99/month SaaS 7. From Static HTML to SvelteKit Dashboard Overnight (you are here) 8. Heartbeat Monitoring: When Your Job Should Ping You 9. Encrypted Secrets, API Keys, and Security 10. From Abidjan to Production: Launching 0cron.dev