Skip to main content
Alpha

Customize Fresh UI

Goal: make the scaffolded frontend yours — edit a route, add an interactive island, restyle a component, install more UI primitives, and re-theme the app — without fighting the framework. NetScript scaffolds a real Fresh 2 application at apps/dashboard/, powered by the @netscript/fresh meta-framework, and the UI components live in your repository as copied source. After the copy, that code is yours to change.

This is a task-oriented recipe. It assumes you already have a NetScript workspace (created with netscript init) whose apps/dashboard/ app type-checks and runs. For the full generated API of the building blocks, follow the reference links at the end — this page never duplicates the reference.

Before you start

You need:

  • An existing NetScript workspace with the scaffolded apps/dashboard/ Fresh app. If you do not have one yet, create it first (netscript init) — see the Quickstart or the Storefront tutorial.
  • The netscript command on your path. Run netscript --help to confirm, and netscript ui:init --help / netscript ui:add --help for the exact option spelling in your installed version. If netscript is not found, install it with deno install --global --allow-all --name netscript jsr:@netscript/cli@0.0.1-alpha.12.

Run the app while you work so you can see each change live. Aspire is step 2 of the normal startup flow — it brings up your database (Postgres by default; or mysql / mssql / sqlite chosen at scaffold time via --db) and Redis before any netscript db command and orchestrates the dashboard for you. You can also run the Fresh app on its own when you only need the UI loop:

# Brings up Postgres + Redis AND the dashboard; Aspire dashboard graph at http://localhost:18888
cd aspire && aspire start
# Leaner single-process loop — Vite dev server with HMR
deno task --cwd apps/dashboard dev

Where the app lives

Everything for the frontend is under apps/dashboard/. The layout follows Fresh 2 file-system routing, with NetScript conventions layered on top:

apps/dashboard/ — what each path owns
NameTypeDescription
main.ts app entry Bootstraps the Fresh app with defineFreshApp from @netscript/fresh/server. Reads PORT and prints the startup banner you see in aspire start logs.
routes/ pages + layouts File-system routes. index.tsx, dashboard.tsx, health.tsx, examples/, plus _app.tsx (HTML shell) and _layout.tsx (chrome). Groups like (_components)/, (_shared)/, and (_islands)/ are non-routing co-location folders.
islands/ client interactivity Hydrated Preact components (ThemeToggle, SidebarToggle, Toast). Everything else renders on the server only.
components/ui/ app-owned UI The copied @netscript/fresh-ui primitives — Button, Card, Badge, PageHeader, StatsGrid, and friends. Edit these freely; they are yours after the copy. Barrel at components/ui/mod.ts.
assets/ styling + tokens tokens.css / tokens.json (the --ns-* design tokens), styles.css, design.css, and per-component CSS under assets/ui/. Tailwind v4 is wired through Vite.
lib/ app helpers cn.ts (class merge), example-service.ts, public-types.ts — non-UI utilities the routes import.
router.ts / utils.ts typed wiring router.ts exposes typed route references (appRoutes); utils.ts exports the createDefine() define helper and the definePage() builder.
vite.config.ts / deno.json build + deps Vite + Fresh + Tailwind plugins and the @app/* aliases; deno.json pins fresh, preact, @preact/signals, tailwindcss, and the @netscript/fresh* imports.

The app entry is one call — defineFreshApp keeps the static-file and filesystem-route bootstrap framework-managed, so you only author routes, islands, and components:

import { defineFreshApp } from '@netscript/fresh/server';
import type { State } from '@app/utils.ts';

export const app = defineFreshApp<State>({ name: 'dashboard' });

// PORT is supplied by Aspire; defaults locally. The banner shows in `aspire start` logs.
const port = parseInt(Deno.env.get('PORT') || '8010');
console.log(`[dashboard] listening on http://localhost:${port}`);

Edit a route

Routes are plain .tsx files under routes/. The scaffold uses the definePage() builder (from @app/utils.ts) so a page declares its route, metadata, and the view it renders in one typed chain. To change the home page, edit routes/(_components)/home-view.tsx (the presentational view) and/or routes/index.tsx (the page declaration and its data):

import HomeView from './(_components)/home-view.tsx';
import { appRoutes } from '@app/router.ts';
import { definePage } from '@app/utils.ts';

export const homePage = definePage()
  .withRoute(appRoutes.home)
  .withMeta(() => ({ title: 'my-app — dashboard', description: 'My app overview.' }))
  .withLayer('home', HomeView, () => ({
    projectName: 'my-app',
    appName: 'dashboard',
    routes: [
      { title: 'Dashboard', href: appRoutes.dashboard.href(), description: 'Operational overview.', cta: 'Open', badge: 'app' },
    ],
  }))
  .withLayout((slots) => slots.home())
  .build();

export const { default: page } = homePage;
export { page as default };
// routes/status.tsx — a new route at /status
import { definePage } from '@app/utils.ts';
import { appRoutes } from '@app/router.ts';

export const statusPage = definePage()
  .withRoute(appRoutes.home) // swap for a typed route once you add it to router.ts
  .withMeta(() => ({ title: 'Status' }))
  .withLayer('status', () => <main class='ns-shell ns-section'>All systems go.</main>, () => ({}))
  .withLayout((slots) => slots.status())
  .build();

export const { default: page } = statusPage;
export { page as default };

The HTML shell (<html>, <head>, fonts, the theme-seed script) is routes/_app.tsx; the top bar / navigation chrome wrapping content pages is routes/_layout.tsx. Both use the define.page(...) / define.layout(...) helpers from createDefine<State>(). Edit those to change the global frame rather than repeating markup per page.

Add interactivity with an island

Server-rendered routes ship zero client JavaScript. When you need state in the browser — a toggle, a form, a live panel — add an island under islands/. Islands are the only components Fresh hydrates on the client. The scaffold ships ThemeToggle, SidebarToggle, and Toast under islands/ui/ as working examples; model new ones on them using Preact signals:

import { useSignal } from '@preact/signals';
import type { VNode } from 'preact';

const Counter = (): VNode => {
  const count = useSignal(0);
  const increment = () => { count.value += 1; };
  return (
    <button type='button' onClick={increment} class='ns-button'>
      Clicked {count.value} times
    </button>
  );
};

export default Counter;
// routes/(_components)/home-view.tsx
import Counter from '@app/islands/Counter.tsx';

const HomeView = () => {
  return (
    <main class='ns-shell ns-section'>
      {/* Rendered on the server, hydrated on the client */}
      <Counter />
    </main>
  );
};

export default HomeView;

A few rules keep islands cheap and correct: keep them small and leaf-shaped; pass plain serializable props in (the island boundary is the hydration boundary); and declare interactive event handlers with arrow functions, as in the scaffolded ThemeToggle. Anything that does not need browser state should stay a plain component in components/ so it ships no JS.

Restyle: tokens, Tailwind, and component CSS

NetScript styling has three layers, lightest-touch first:

1 · Design tokens (theme-wide)

Edit the CSS custom properties in assets/tokens.css (mirrored in tokens.json). Tokens are named --ns-* (e.g. surface, border, foreground, accent) and drive every component plus the light/dark themes selected by the data-theme attribute. Change a token once and the whole app re-themes.

2 · Tailwind utilities (per element)

Tailwind v4 is wired through Vite (@tailwindcss/vite). Use utility classes in JSX for one-off layout and spacing. The scaffold also defines NetScript layout helpers (ns-shell, ns-section, ns-stack, ns-cluster) in assets/layouts.css for consistent page rhythm.

3 · Component CSS (one primitive)

Each copied primitive has its own stylesheet under assets/ui/ (button.css, card.css, badge.css, …). To restyle just one component everywhere, edit its file there — it is app-owned source, imported by assets/styles.css.

Pick the lightest layer that does the job: reach for a token when the change should re-theme the whole app, a Tailwind utility for one-off element layout, and component CSS when one primitive should look different everywhere it appears.

Theme switching is already wired: routes/_app.tsx seeds data-theme from the ns-theme localStorage key (falling back to the OS preference), and the ThemeToggle island flips it at runtime. To ship a different default, change the data-theme attribute on <html> in _app.tsx and adjust the token values for that theme in assets/tokens.css.

Install more UI from the registry

When you need a primitive the scaffold didn't copy in, pull it from the @netscript/fresh-ui registry with the CLI. Two commands do the work, and both copy source files into your app rather than adding a runtime dependency:

Fresh UI CLI commands
NameTypeDescription
netscript ui:init install the foundation Installs the NetScript Fresh UI foundation set into an app workspace. Run once when setting up UI in an app that doesn't have it yet (the scaffold runs the equivalent for you).
netscript ui:add add one item or collection Copies a single registry item or a named collection into the app workspace — component files go to apps/dashboard/components/ui/, island files to islands/ui/, lib helpers to lib/, and assets to assets/ui/ — then wires the CSS and merges any required deno.json imports.
# Add one component (or a named collection) to the dashboard app
netscript ui:add data-table

Both commands accept the same useful flags (run --help for the version-accurate list):

  • --project-root <path> — target a workspace other than the current directory.
  • --theme <name> — install against a specific theme registry item instead of the default official theme.
  • --registry-root <path> — override the Fresh UI package root (advanced/local development).
  • --force — overwrite existing copied UI files when re-running.

After the copy, the new component is regular source under components/ui/ (and its styles under assets/ui/). Import it through the @app/components/ui/mod.ts barrel like the rest, then edit it however you like — there is no upstream patch to keep in sync.

Verify your changes

With the app running, confirm the loop end to end:

  1. Watch the Vite dev server (or aspire start logs) recompile on save — Fresh hot module replacement updates the page without a full reload.
  2. Open /design/components to see restyled primitives render against the active theme, and /design/tokens to confirm token edits took effect.
  3. Toggle the theme (the ThemeToggle in the top bar) to check both light and dark look right after token changes.
  4. Type-check the app before committing:
deno task --cwd apps/dashboard check

That task runs deno fmt --check, deno lint, and deno check over the app, so a clean run means your routes, islands, and edited components still type and lint.

Next steps

  • Capability hub: Fresh UI — the concept, the headline API, and the Learn / Do / Reference triplet for the dashboard app.
  • Reference: the generated API for the UI registry and the Fresh runtime — @netscript/fresh-ui and @netscript/fresh. These are the authority for every export (the /server, /query, and sibling subpaths included); this guide never duplicates them.
  • Related recipes: Add a service to give your UI a typed oRPC backend, and Add OpenTelemetry to trace it.
  • Concepts: the contracts explanation shows how a typed contract flows from service to client to island.