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
netscriptcommand on your path. Runnetscript --helpto confirm, andnetscript ui:init --help/netscript ui:add --helpfor the exact option spelling in your installed version. Ifnetscriptis not found, install it withdeno 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:
| Name | Type | Description |
|---|---|---|
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 |
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:
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.
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.
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:
| Name | Type | Description |
|---|---|---|
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:
- Watch the Vite dev server (or
aspire startlogs) recompile on save — Fresh hot module replacement updates the page without a full reload. - Open
/design/componentsto see restyled primitives render against the active theme, and/design/tokensto confirm token edits took effect. - Toggle the theme (the
ThemeTogglein the top bar) to check both light and dark look right after token changes. - 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-uiand@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.