Development
Local development guide for the RawStack Web component.
Running the Web app locally
The Web app is built with Next.js, which handles the application structure, routing, rendering, and local development workflow. During development, Next.js serves the app locally with the Turbopack dev server.
If you are new to the stack or want a refresher on conventions, the official Next.js documentation is well worth reading before you go deeper into the Web codebase.
To get the Web app running locally, follow these steps:
cd apps/web
npm install
cp .env.dist .env.local
npm run devThe Web app is available at http://localhost:3000.
Environment configuration
Because the Web app uses the BFF pattern, the API URL is a server-side environment variable. It is never exposed to the browser.
The quick start uses:
API_URL=http://localhost:3001/v1Additional server-side environment variables for third-party services can also be added to .env.local. Any variable that needs to be available in the browser must be prefixed with NEXT_PUBLIC_, but sensitive values such as API URLs and credentials should remain server-side.
The BFF pattern
The Web app acts as a Backend for Frontend (BFF). Rather than making requests directly from the browser to the core API, all communication with the API goes through Next.js Server Actions. This keeps sensitive credentials — including authentication tokens — entirely server-side and out of the browser.
At a high level, the flow works like this:
- The user interacts with the UI.
- The browser calls a Server Action (a server-side function exposed by Next.js).
- The Server Action reads the authenticated session from an
HttpOnlycookie, calls the core API using@rawstack/api-client, and returns the result. - The UI updates with the response.
This approach follows current best practices for Next.js applications. It eliminates the need to pass tokens to the client, reduces the attack surface for token theft, and keeps API interaction contained to the server layer.
Data fetching and UI models
Data flows through three distinct layers, each with a clear responsibility:
- Server Actions call the core API and return plain data transfer objects (DTOs).
- Hooks receive DTOs, convert them into UI models, and expose those models to components.
- Components only ever deal with UI models. They have no knowledge of the API, DTOs, or how data was fetched.
This separation keeps API concerns in the server layer, transformation logic in the hook layer, and presentation concerns in the component layer.
Server Actions
Server Actions run on the server and have access to the authenticated session. They call the core API using @rawstack/api-client, map the response to a plain UserDTO, and return that to the caller. They do not return raw API types or UI models.
'use server';
import { createSessionApi } from '@/lib/api/session-api';
import { type UserDTO } from '@/lib/model/user-model';
export async function getMe(): Promise<UserDTO | null> {
const ctx = await createSessionApi({ writeSession: true });
if (!ctx) return null;
try {
const { data } = await ctx.api.user.getCurrentUser();
const user = data.item;
return {
id: user.id,
email: user.email,
roles: user.roles,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
unverifiedEmail: user.unverifiedEmail,
};
} catch {
return null;
}
}Hooks
Hooks call Server Actions and convert the returned DTO into a UI model. The component that uses the hook receives a UserModel and nothing else.
import { useEffect, useState } from 'react';
import { getMe } from '@/actions/user';
import UserModel from '@/lib/model/user-model';
export function useCurrentUser() {
const [user, setUser] = useState<UserModel | null>(null);
useEffect(() => {
getMe()
.then((dto) => { if (dto) setUser(UserModel.createFromDTO(dto)); })
.catch(() => {});
}, []);
return { user };
}For mutations, hooks call Server Actions and convert any returned DTO before exposing the result. Here is a hook that updates the user's account:
import { updateUser } from '@/actions/user';
import UserModel from '@/lib/model/user-model';
import { useMutationWithCallbacks, type UseMutationWithCallbacksOptions } from '@/hooks/use-mutation-with-callbacks';
interface UpdateAccountParams {
userId: string;
email: string;
password?: string;
}
export function useUpdateAccount(options?: UseMutationWithCallbacksOptions<UserModel>) {
const { mutate, isPending } = useMutationWithCallbacks(
async (data: UpdateAccountParams) => {
const result = await updateUser(data.userId, {
email: data.email,
password: data.password || undefined,
});
if (!result.ok) {
const err = Object.assign(new Error(result.error.message), { statusCode: result.error.statusCode });
throw err;
}
return UserModel.createFromDTO(result.user);
},
options,
);
return { updateAccount: mutate, isBusy: isPending };
}UI models
UI models are classes that represent domain concepts in terms the UI understands. They are kept separate from the API client types so that a change to the API contract does not ripple directly into the component layer.
Here is the UserModel:
import dayjs, { Dayjs } from 'dayjs';
export type UserDTO = {
id: string;
email: string;
roles: string[];
createdAt: string;
updatedAt: string;
unverifiedEmail?: string;
};
export default class UserModel {
public readonly dateCreated: Dayjs;
public readonly dateUpdated: Dayjs;
constructor(
public readonly id: string,
public readonly email: string,
public readonly roles: string[],
createdAt: string,
updatedAt: string,
public readonly unverifiedEmail?: string,
) {
this.dateCreated = dayjs(createdAt);
this.dateUpdated = dayjs(updatedAt);
}
get isAdmin(): boolean {
return this.roles.includes('ADMIN');
}
get isVerified(): boolean {
return this.roles.includes('VERIFIED_USER');
}
static createFromDTO(dto: UserDTO): UserModel {
return new UserModel(dto.id, dto.email, dto.roles, dto.createdAt, dto.updatedAt, dto.unverifiedEmail);
}
}The UserDTO is a plain serialisable object that Server Actions can return safely across the server/client boundary. The createFromDTO factory method handles the conversion in the hook layer. Components never see the DTO directly.
Project structure
The Web app follows a standard Next.js project structure.
app: App Router pages, layouts, loading states, and route segmentscomponents: shared React componentslib: API helpers, Server Actions, utilities, and UI modelspublic: static assetsspecs: feature documentation and supporting notesDockerfile: production container build definition
Additional implementation details
API client
The @rawstack/api-client package is shared across the RawStack codebase and can be regenerated when the backend contract changes.
From the repo root:
./scripts/generate-api-client.shAfter regenerating the client, rebuild or restart the Web app so it picks up the updated generated code and types.
Path alias
@/* is aliased to the Web app source directory, making imports easier to read and maintain than long relative paths.
Sharing UI models
UserModel is an example of a UI-facing model that could be shared across the Admin, Web, and mobile apps in the RawStack codebase.
In these examples, models are kept close to each application to keep the structure simple and easy to follow. In a larger production codebase, you may want to move shared models into a separate package so they can be reused across clients while still keeping API concerns isolated from the UI.
Useful scripts
npm run dev: start the Next.js dev server with Turbopacknpm run build: create a production buildnpm run start: run the production build locallynpm run lint: run ESLintnpm run test: run Vitest in watch modenpm run test:run: run tests oncenpm run test:e2e: run Playwright end-to-end tests