When we started building 0fee.dev's frontend, we chose SolidJS over React. The decision was deliberate: SolidJS offers true reactivity without a virtual DOM, resulting in smaller bundles and faster updates -- critical for a dashboard that displays real-time transaction data, manages API keys, and handles payment configuration.
This article covers the SolidJS store architecture, component structure, the 3-layout routing system, and the transition from mock data to real API integration in Session 006.
Why SolidJS
The choice between SolidJS and React came down to three factors:
| Factor | React | SolidJS |
|---|---|---|
| Bundle size | ~45KB (minified) | ~7KB (minified) |
| Reactivity | Virtual DOM diffing | Fine-grained signals |
| Learning curve | Familiar to most devs | Similar JSX syntax |
| Ecosystem | Massive | Growing but smaller |
For a payment dashboard where every kilobyte matters (developers embed our checkout widget on their sites), SolidJS's 7KB footprint was compelling. The reactivity model also maps naturally to real-time payment status updates -- when a transaction status changes, only the affected DOM node re-renders.
Store Architecture
SolidJS stores are the state management layer. We built three primary stores:
Auth Store
typescriptimport { createStore } from "solid-js/store";
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
const [authState, setAuthState] = createStore<AuthState>({
user: null,
token: localStorage.getItem("auth_token"),
isAuthenticated: false,
isLoading: true,
});
export async function login(email: string, password: string) {
const response = await apiClient.post("/v1/auth/login", {
email,
password,
});
const { user, token } = response.data;
localStorage.setItem("auth_token", token);
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
}
export async function logout() {
await apiClient.post("/v1/auth/logout");
localStorage.removeItem("auth_token");
setAuthState({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}
export { authState };The auth store persists the JWT token in localStorage with a 30-day expiry. On app load, it checks for an existing token and validates it against the backend.
Apps Store
typescriptimport { createStore } from "solid-js/store";
interface AppsState {
apps: App[];
currentApp: App | null;
apiKeys: ApiKey[];
providers: ProviderCredential[];
isLoading: boolean;
}
const [appsState, setAppsState] = createStore<AppsState>({
apps: [],
currentApp: null,
apiKeys: [],
providers: [],
isLoading: true,
});
export async function fetchApps() {
setAppsState("isLoading", true);
const response = await apiClient.get("/v1/apps");
setAppsState({
apps: response.data,
isLoading: false,
});
}
export async function createApp(name: string, environment: string) {
const response = await apiClient.post("/v1/apps", {
name,
environment,
});
setAppsState("apps", (prev) => [...prev, response.data]);
return response.data;
}
export async function createApiKey(appId: string, name: string) {
const response = await apiClient.post(`/v1/apps/${appId}/keys`, {
name,
});
setAppsState("apiKeys", (prev) => [...prev, response.data]);
return response.data;
}Transactions Store
typescriptimport { createStore } from "solid-js/store";
interface TransactionsState {
transactions: Transaction[];
total: number;
page: number;
perPage: number;
filters: {
status: string | null;
provider: string | null;
dateFrom: string | null;
dateTo: string | null;
};
isLoading: boolean;
}
const [txState, setTxState] = createStore<TransactionsState>({
transactions: [],
total: 0,
page: 1,
perPage: 20,
filters: {
status: null,
provider: null,
dateFrom: null,
dateTo: null,
},
isLoading: true,
});
export async function fetchTransactions() {
setTxState("isLoading", true);
const params = new URLSearchParams({
page: String(txState.page),
per_page: String(txState.perPage),
});
if (txState.filters.status) params.set("status", txState.filters.status);
if (txState.filters.provider) params.set("provider", txState.filters.provider);
const response = await apiClient.get(`/v1/payments?${params}`);
setTxState({
transactions: response.data.items,
total: response.data.total,
isLoading: false,
});
}Component Architecture
The dashboard uses a hierarchical component structure:
App.tsx
├── Marketing Layout
│ ├── Navbar.tsx
│ ├── [Page Content]
│ └── Footer.tsx
├── Auth Layout
│ └── [Centered Content]
└── Dashboard Layout
├── Sidebar.tsx
├── Header.tsx
└── [Page Content]Sidebar Component
The sidebar handles navigation, environment switching, and collapsed/expanded states:
tsximport { A, useLocation } from "@solidjs/router";
import { createSignal } from "solid-js";
export default function Sidebar() {
const [collapsed, setCollapsed] = createSignal(false);
const location = useLocation();
const menuItems = [
{ path: "/dashboard", label: "Dash", icon: HomeIcon },
{ path: "/apps", label: "Apps", icon: AppsIcon },
{ path: "/transactions", label: "Transactions", icon: TxIcon },
{ path: "/customers", label: "Customers", icon: UsersIcon },
{ path: "/wallet", label: "Wallet", icon: WalletIcon },
{ path: "/payment-links", label: "Links", icon: LinkIcon },
{ path: "/webhooks", label: "Webhooks", icon: WebhookIcon },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
return (
<aside class={`${collapsed() ? "w-[72px]" : "w-64"} transition-all`}>
<div class="flex items-center justify-between p-4">
<Show when={!collapsed()}>
<span class="font-bold text-lg">0fee.dev</span>
</Show>
<button onClick={() => setCollapsed(!collapsed())}>
<MenuIcon />
</button>
</div>
<nav class="mt-4">
<For each={menuItems}>
{(item) => (
<A
href={item.path}
class={`flex items-center gap-3 px-4 py-3.5 ${
location.pathname === item.path
? "bg-emerald-50 text-emerald-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<item.icon class="w-5 h-5" />
<Show when={!collapsed()}>
<span>{item.label}</span>
</Show>
</A>
)}
</For>
</nav>
</aside>
);
}Header Component
The header displays the current page title, environment toggle, and user controls:
tsxexport default function Header() {
const [environment, setEnvironment] = createSignal("sandbox");
return (
<header class="h-14 border-b flex items-center justify-between px-6">
<div class="flex items-center gap-4">
<h1 class="text-lg font-semibold">{currentPageTitle()}</h1>
</div>
<div class="flex items-center gap-3">
{/* Environment toggle */}
<button
class={`px-3 py-1 rounded text-sm ${
environment() === "live"
? "bg-emerald-500 text-white"
: "bg-amber-100 text-amber-800"
}`}
onClick={() => setEnvironment(
environment() === "live" ? "sandbox" : "live"
)}
>
{environment() === "live" ? "Live Mode" : "Test Mode"}
</button>
{/* Currency selector */}
<CurrencySelector />
{/* Language switcher */}
<LanguageSwitcher />
{/* User menu */}
<UserMenu />
</div>
</header>
);
}Dashboard Pages
The dashboard consists of 19 pages, each with a specific function:
| Page | Route | Purpose |
|---|---|---|
| Dashboard | /dashboard | Stats overview, recent transactions |
| Apps | /apps | App management, API keys, providers |
| Transactions | /transactions | Transaction listing with filters |
| Customers | /customers | Customer management |
| Wallet | /wallet | Balance and wallet operations |
| Settings | /settings | Profile, security, webhooks |
| GetStarted | /get-started | Onboarding stepper |
| Webhooks | /webhooks | Webhook configuration |
| SDKs | /sdks | SDK downloads and docs |
| DeveloperConsole | /developer-console | API testing, widget preview |
| PaymentLinks | /payment-links | Shareable payment URLs |
| PaymentMethods | /payment-methods | Available methods |
| Countries | /countries | Coverage map |
| Providers | /providers | Provider status |
| Invoices | /invoices | Invoice management |
| Teams | /teams | Team member management |
| AddFunds | /add-funds | Wallet top-up |
| CreditHistory | /credit-history | Credit transaction log |
| FeatureRequests | /feature-requests | Developer feedback |
The 3-Layout System
Session 008 introduced the 3-layout routing system, which determines the UI chrome based on the route:
tsx// App.tsx
import { Router, Route } from "@solidjs/router";
function App() {
return (
<Router>
{/* Marketing Layout: Navbar + Footer */}
<Route path="/" component={MarketingLayout}>
<Route path="/" component={Home} />
<Route path="/products" component={Products} />
<Route path="/pricing" component={Pricing} />
<Route path="/docs" component={Docs} />
<Route path="/about" component={About} />
<Route path="/contact" component={Contact} />
<Route path="/providers" component={ProvidersPage} />
<Route path="/coverage" component={CountriesPage} />
<Route path="/payment-methods" component={MethodsPage} />
<Route path="/status" component={StatusPage} />
<Route path="/how-it-works" component={HowItWorks} />
</Route>
{/* Auth Layout: Minimal centered */}
<Route path="/" component={AuthLayout}>
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/oauth/callback" component={OAuthCallback} />
</Route>
{/* Dashboard Layout: Sidebar + Header */}
<Route path="/" component={DashboardLayout}>
<Route path="/dashboard" component={Dashboard} />
<Route path="/apps" component={Apps} />
<Route path="/transactions" component={Transactions} />
{/* ... 16 more dashboard routes */}
</Route>
{/* Standalone pages (no layout) */}
<Route path="/payment/success" component={PaymentResult} />
<Route path="/payment/cancel" component={PaymentResult} />
<Route path="/pay/:linkId" component={PaymentLinkPage} />
</Router>
);
}The three layouts serve distinct audiences:
| Layout | Audience | Chrome | Routes |
|---|---|---|---|
| Marketing | Visitors | Navbar + Footer | /, /products, /pricing, /docs |
| Auth | Users logging in | Minimal centered | /login, /register |
| Dashboard | Authenticated developers | Sidebar + Header | /dashboard, /apps, /transactions |
From Mock Data to Real API
Session 002 created the dashboard with hardcoded mock data. Session 006 connected it to real APIs:
Before (Mock Data)
tsx// Dashboard.tsx -- Session 002
function Dashboard() {
const stats = {
totalTransactions: 1234,
totalVolume: 56789.00,
successRate: 98.5,
activeApps: 3,
};
return (
<div class="grid grid-cols-4 gap-4">
<StatCard title="Transactions" value={stats.totalTransactions} />
<StatCard title="Volume" value={`$${stats.totalVolume}`} />
<StatCard title="Success Rate" value={`${stats.successRate}%`} />
<StatCard title="Active Apps" value={stats.activeApps} />
</div>
);
}After (Real API)
tsx// Dashboard.tsx -- Session 006
function Dashboard() {
const [stats] = createResource(async () => {
const payments = await apiClient.get("/v1/payments?per_page=1");
const apps = await apiClient.get("/v1/apps");
return {
totalTransactions: payments.data.total,
totalVolume: payments.data.items.reduce(
(sum, tx) => sum + tx.amount, 0
),
successRate: calculateSuccessRate(payments.data.items),
activeApps: apps.data.length,
};
});
return (
<Suspense fallback={<LoadingSkeleton />}>
<div class="grid grid-cols-4 gap-4">
<StatCard title="Transactions" value={stats()?.totalTransactions} />
<StatCard title="Volume" value={formatCurrency(stats()?.totalVolume)} />
<StatCard title="Success Rate" value={`${stats()?.successRate}%`} />
<StatCard title="Active Apps" value={stats()?.activeApps} />
</div>
</Suspense>
);
}The transition required fixing authentication first. Session 006 discovered that the auth middleware only supported API keys, not session tokens. After adding session token verification to the middleware, all dashboard pages could fetch real data.
API Client
The API client handles authentication, error responses, and base URL configuration:
typescriptimport axios from "axios";
const apiClient = axios.create({
baseURL: "/api", // Proxied to backend via Vite config
});
// Add auth token to every request
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("auth_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 responses
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Only logout for auth-related 401s
const isAuthEndpoint = error.config.url?.includes("/auth/");
if (!isAuthEndpoint) {
console.warn("Session expired -- please log in again");
}
}
return Promise.reject(error);
}
);
export default apiClient;The Vite dev server proxies /api/* requests to the backend at localhost:8000:
typescript// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
rewrite: (path) => path.replace(/^\/api/, "/v1"),
},
},
},
});What We Learned
Building the SolidJS dashboard taught us three things:
- Start with real APIs, not mock data. Session 002 built the entire dashboard with hardcoded data. Session 006 had to rewrite every page to use real APIs. Starting with API integration from the beginning would have saved a full session of rework.
- Three layouts cover every use case. Marketing (public), Auth (minimal), and Dashboard (authenticated) handle every page type. Adding new pages is just a matter of placing them in the right layout group.
- SolidJS stores are elegant for payment data. The fine-grained reactivity means that when a transaction status changes from "pending" to "completed," only that transaction's row re-renders. No virtual DOM diffing, no unnecessary re-renders of the entire transaction list.
The dashboard was built in Session 002 and progressively enhanced through dozens of subsequent sessions. It grew from 5 pages to 19, from mock data to real API integration, and from light-only to full dark mode support.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.