Back to 0fee
0fee

Building a SolidJS Dashboard From Scratch

How we built 0fee.dev's SolidJS dashboard with stores, component architecture, real API integration, and 3-layout routing system.

Thales & Claude | March 25, 2026 10 min 0fee
solidjsdashboardfrontendcomponent-architecturestores

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:

FactorReactSolidJS
Bundle size~45KB (minified)~7KB (minified)
ReactivityVirtual DOM diffingFine-grained signals
Learning curveFamiliar to most devsSimilar JSX syntax
EcosystemMassiveGrowing 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:

PageRoutePurpose
Dashboard/dashboardStats overview, recent transactions
Apps/appsApp management, API keys, providers
Transactions/transactionsTransaction listing with filters
Customers/customersCustomer management
Wallet/walletBalance and wallet operations
Settings/settingsProfile, security, webhooks
GetStarted/get-startedOnboarding stepper
Webhooks/webhooksWebhook configuration
SDKs/sdksSDK downloads and docs
DeveloperConsole/developer-consoleAPI testing, widget preview
PaymentLinks/payment-linksShareable payment URLs
PaymentMethods/payment-methodsAvailable methods
Countries/countriesCoverage map
Providers/providersProvider status
Invoices/invoicesInvoice management
Teams/teamsTeam member management
AddFunds/add-fundsWallet top-up
CreditHistory/credit-historyCredit transaction log
FeatureRequests/feature-requestsDeveloper 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:

LayoutAudienceChromeRoutes
MarketingVisitorsNavbar + Footer/, /products, /pricing, /docs
AuthUsers logging inMinimal centered/login, /register
DashboardAuthenticated developersSidebar + 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:

  1. 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.
  1. 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.
  1. 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles