Deep DivesWeb

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 dev

The 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/v1

Additional 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:

  1. The user interacts with the UI.
  2. The browser calls a Server Action (a server-side function exposed by Next.js).
  3. The Server Action reads the authenticated session from an HttpOnly cookie, calls the core API using @rawstack/api-client, and returns the result.
  4. 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 segments
  • components: shared React components
  • lib: API helpers, Server Actions, utilities, and UI models
  • public: static assets
  • specs: feature documentation and supporting notes
  • Dockerfile: 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.sh

After 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 Turbopack
  • npm run build: create a production build
  • npm run start: run the production build locally
  • npm run lint: run ESLint
  • npm run test: run Vitest in watch mode
  • npm run test:run: run tests once
  • npm run test:e2e: run Playwright end-to-end tests