Skip to main content

Step 4 — Frontend

By the end: a React app that signs in and lists users using the backend service contract — no codegen.

Scaffold the frontend

cd apps
pnpm create vite web --template react-ts
cd web
pnpm install

apps/web/package.json — add deps:

pnpm add @omnitron-dev/netron-browser @omnitron-dev/netron-react @omnitron-dev/prism react-router-dom@7

Share the service types

The frontend imports the backend's service interfaces directly. Easiest way: a workspace package.

mkdir -p packages/api-contracts/src

packages/api-contracts/package.json:

{
"name": "@my-platform/api-contracts",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"private": true
}

packages/api-contracts/src/index.ts:

export interface User {
id: string;
email: string;
name: string;
roles: string[];
}

export interface AuthService {
signIn(input: { email: string; password: string }): Promise<{
token: string;
user: Pick<User, 'id' | 'email' | 'roles'>;
}>;
signOut(input: { sessionId: string }): Promise<{ success: boolean }>;
}

export interface UsersService {
findById(id: string): Promise<User>;
list(): Promise<User[]>;
}

In apps/api/, import these and use as the implementation type:

// apps/api/src/users/users.service.ts
import type { UsersService as UsersServiceContract, User } from '@my-platform/api-contracts';

@Service('users@1.0.0')
export class UsersService implements UsersServiceContract {
// ...
}

And in apps/web/:

pnpm add @my-platform/api-contracts@workspace:*

The client

apps/web/src/client.ts:

import { NetronReactClient } from '@omnitron-dev/netron-react';

export const client = new NetronReactClient({
url: import.meta.env.VITE_API_URL ?? 'http://localhost:3001',
transport: 'http',
auth: {
storage: 'localStorage',
},
});

Providers

apps/web/src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { PrismProvider } from '@omnitron-dev/prism/core';
import { createTheme } from '@omnitron-dev/prism/theme';
import { NetronProvider } from '@omnitron-dev/netron-react';
import { AuthProvider } from '@omnitron-dev/netron-react/auth';

import { client } from './client.js';
import App from './App.js';

const theme = createTheme({ mode: 'dark', palette: { primary: { main: '#7c4dff' } } });

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<NetronProvider client={client}>
<AuthProvider>
<PrismProvider theme={theme}>
<BrowserRouter>
<App />
</BrowserRouter>
</PrismProvider>
</AuthProvider>
</NetronProvider>
</React.StrictMode>,
);

Sign-in page

apps/web/src/SignInPage.tsx:

import { useState } from 'react';
import { useAuth } from '@omnitron-dev/netron-react/auth';
import { useService } from '@omnitron-dev/netron-react';
import { useNavigate } from 'react-router-dom';
import type { AuthService } from '@my-platform/api-contracts';

export function SignInPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string>();
const navigate = useNavigate();

const auth = useService<AuthService>('auth');
const authMgr = useAuth();

const signIn = auth.signIn.useMutation({
onSuccess: async (result) => {
await authMgr.setTokens({ accessToken: result.token, user: result.user });
navigate('/');
},
onError: (e) => setError(e instanceof Error ? e.message : String(e)),
});

return (
<form onSubmit={(e) => { e.preventDefault(); signIn.mutate({ email, password }); }}>
<h1>Sign in</h1>
{error && <p style={{ color: 'red' }}>{error}</p>}
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit" disabled={signIn.isPending}>
{signIn.isPending ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}

Users list page

apps/web/src/UsersPage.tsx:

import { useService } from '@omnitron-dev/netron-react';
import type { UsersService } from '@my-platform/api-contracts';

export function UsersPage() {
const users = useService<UsersService>('users');
const { data, isLoading, error } = users.list.useQuery([]);

if (isLoading) return <p>Loading…</p>;
if (error) return <p style={{ color: 'red' }}>{error.message}</p>;

return (
<ul>
{data?.map(u => (
<li key={u.id}>
{u.name}{u.email} — [{u.roles.join(', ')}]
</li>
))}
</ul>
);
}

Routes

apps/web/src/App.tsx:

import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthGuard, GuestGuard } from '@omnitron-dev/netron-react/auth';
import { SignInPage } from './SignInPage.js';
import { UsersPage } from './UsersPage.js';

export default function App() {
return (
<Routes>
<Route path="/sign-in" element={
<GuestGuard><SignInPage /></GuestGuard>
} />
<Route path="/" element={
<AuthGuard><UsersPage /></AuthGuard>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

Run

In two terminals:

# api:
cd apps/api && JWT_SECRET=dev-secret-do-not-use-in-prod pnpm dev

# web:
cd apps/web && pnpm dev

Open http://localhost:5173/:

  1. Redirected to /sign-in.
  2. Enter admin@example.com / correct-horse-battery-staple.
  3. Redirected to / — users list renders.

Note the type flow

When you call:

const users = useService<UsersService>('users');
const { data } = users.list.useQuery([]);
// ^? User[] | undefined

data is User[] | undefined — typed from the shared contract. Change UsersService.list to return User[] instead of Pick<User, ...> on the server, the frontend's data.email access fails the build.

No codegen. No schema sync.

Verify auth gating

Replace admin@example.com with a non-admin user; you'll see FORBIDDEN from the backend, surfaced as error in the useQuery hook. The <AuthGuard> redirected non-auth requests back to /sign-in.

Commit

git add .
git commit -m "step 4: React UI with end-to-end types"

Next

Step 5 — Tests → — cover the stack with unit + integration + E2E tests.

Troubleshooting

SymptomFix
CORS errorAdd cors: true to api's netron.http config
data typed as unknownVerify @my-platform/api-contracts is installed in apps/web
useService undefinedCheck <NetronProvider client={client}> wraps <App>
Sign-in succeeds but list 401sAuth manager not attaching token; check AuthProvider order in providers