Example application

One entity, the whole loop. A Note model, a NestJS API that stores notes in MongoDB behind a login, and an Angular frontend whose list, form and details pages are generated from that model. The application lives in the repository under docs/examples/app, is built and tested against the framework sources on every pull request, and every fragment on this page is cut from it.


What it is

The application is the shortest path from an installed package to a working screen. It has three parts.

  • libs/model holds the Note entity, decorated with @Model and @Field. Both applications import it through the @app/model alias, so the frontend and the backend agree on one description of the data.
  • apps/api is the backend: AuthShellNestjsModule issues tokens for the seeded user and CrudShellNestjsModule exposes the notes under /api/notes.
  • apps/web is the frontend: the login page on smart-sign-in-form, and the notes feature registered with CrudModule.forFeature, which brings the list, the add form and the item page.

Nothing else is written by hand. There is no notes component, no form template and no HTTP service in the application code, because the framework builds them from the model and one configuration object.

The application lives with the framework

The application is part of this repository, compiles the framework from its sources and runs its Playwright suite in the pull request workflow. When a framework change breaks the loop, the application fails first. Its first build found six defects, all fixed before it landed, and the suite asserts each of those paths since.


The model

One class describes the entity for both sides of the wire.

/** The one entity of the example app: a note with a title and a body. */
@Model({ titleKey: 'title' })
export class Note implements IEntity<string> {
  // Assigned by the API on create.
  id!: string;

  // create/update: in the form (focused first), list: a column, details: on
  // the item page. `required` is repeated per mode because the API validates
  // create and update against the mode block, not the top-level flag.
  @Field({
    type: FieldType.text,
    required: true,
    focused: true,
    create: { required: true },
    update: { required: true },
    list: true,
    details: true,
  })
  title!: string;

  // create/update: in the form, details: on the item page - no list column.
  @Field({
    type: FieldType.longText,
    create: true,
    update: true,
    details: true,
  })
  content?: string;
}

@Model names the field that stands for the record, which the item page uses as its title. Each @Field says what the field is and where it appears: create and update put it in the form, list makes it a column, details shows it on the item page. The required flag is repeated inside the create and update blocks on purpose, because the API validates each request against the block of its mode rather than the top-level flag. focused puts the cursor in the title when the form opens.


The API

The whole backend is one module.

const config = apiConfig();

// The JWT settings are shared by the module that issues tokens (auth) and the
// module that checks them on every CRUD route.
const tokenConfig = {
  secretOrPrivateKey: config.jwt.secret,
  expiredIn: config.jwt.expiresIn,
};

@Module({
  imports: [
    // `AuthShellNestjsModule` stores users through TypeORM and expects the
    // root connection to be registered by the application.
    TypeOrmModule.forRoot({
      type: 'mongodb',
      host: config.mongo.host,
      port: config.mongo.port,
      database: config.mongo.database,
      entities: ENTITIES,
    }),
    TypeOrmModule.forFeature(ENTITIES),

    // POST /api/token: the OAuth password grant for the seeded user.
    AuthShellNestjsModule.forRoot({
      tokenConfig: { ...tokenConfig, clients: [config.clientId] },
    }),

    // The generic CRUD controller for one entity, backed by MongoDB. Its routes
    // are registered at the module root, so `RouterModule` mounts them under
    // /api/notes.
    CrudShellNestjsModule.forRoot({
      tokenConfig,
      permissions: {
        create: ['admin'],
        read: ['admin', 'user'],
        update: ['admin'],
        delete: ['admin'],
      },
      db: {
        host: config.mongo.host,
        port: config.mongo.port,
        database: config.mongo.database,
        collection: 'notes',
        type: Note,
      },
      restApi: true,
      socket: false,
    }),
    RouterModule.register([{ path: 'notes', module: CrudShellNestjsModule }]),
  ],
  providers: [
    { provide: API_CONFIG, useValue: config },
    UsersSeed,
    // Maps DomainValidationError / DomainForbiddenError to 400 / 403.
    { provide: APP_FILTER, useClass: AppExceptionFilter },
  ],
})
export class AppModule {}

Three things happen here. TypeOrmModule.forRoot opens the connection that AuthShellNestjsModule stores users in, and the auth module registers POST /api/token, the OAuth password grant the frontend calls to sign in. CrudShellNestjsModule.forRoot takes the same JWT settings, a permission map that says which roles may create, read, update and delete, and the MongoDB collection the notes live in. Its routes are declared at the module root, so RouterModule.register is what mounts them under /api/notes. The exception filter from @smartsoft001/nestjs turns a domain validation error into a 400 and a domain permission error into a 403.

The bootstrap adds the api prefix and turns on CORS, so a client on another origin can use the API as well as the dev server proxy.

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  // The frontend talks to the API through the dev-server proxy (same origin),
  // but CORS is on so that any other client on another origin can too.
  app.enableCors();
  app.setGlobalPrefix('api');

  const { port } = apiConfig();
  await app.listen(port);

  Logger.log(`API listening on http://localhost:${port}/api`, 'Bootstrap');
}

bootstrap();

The API seeds one user on its first start, so login works before anybody has touched the database.

/** Inserts the admin user when it is missing. Returns true when a user was inserted. */
async seed(): Promise<boolean> {
  const username = this.config.admin.username;

  // `save` needs an `@ObjectIdColumn`, which the shipped `User` entity has not;
  // `insertOne` and `findOneBy` work without one.
  const existing = await this.users.findOneBy({ username });
  if (existing) {
    return false;
  }

  const user = new User();
  user.username = username;
  user.password = await PasswordService.hash(this.config.admin.password);
  user.permissions = ['admin'];
  user.disabled = false;

  await this.users.insertOne(user);

  return true;
}

The port, the database and the seeded credentials come from the environment, with the defaults listed in .env.example next to the compose file.


The frontend

Root providers

The CRUD screens need a few things at the root of the application, and this is the whole list.

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    // The `demo` build adds hash routing here; see router.features.demo.ts.
    provideRouter(appRoutes, ...ROUTER_FEATURES),
    // Every request to the API carries the JWT from the login. The interceptor
    // is a DI provider on purpose; see auth.interceptor.ts for why a functional
    // one would not reach the CRUD routes.
    provideHttpClient(withInterceptorsFromDi()),
    AUTH_INTERCEPTOR_PROVIDER,
    // Empty, except in the `demo` build, where the API is an in-memory double
    // registered the same way; see in-memory/in-memory-api.providers.demo.ts.
    ...IN_MEMORY_API_PROVIDERS,

    // The CRUD feature registers its own reducer and effects at runtime, so
    // NgRx must exist at the root before `CrudModule.forFeature` runs.
    provideStore({}),
    provideEffects([]),

    // `SharedModule` registers the framework's built-in translations and
    // `NgrxSharedModule` connects the store the CRUD reducers are added to.
    provideTranslateService(),
    importProvidersFrom(SharedModule, NgrxSharedModule),

    // The form factory asks this provider for extra validators per field and
    // has no default, so an app without custom rules still registers it and
    // hands back the validators the `@Field` metadata already implies.
    {
      provide: MODEL_VALIDATORS_PROVIDER,
      useValue: {
        get: (options: IModelValidatorsOptions) =>
          Promise.resolve(options.base ?? {}),
      },
    },

    provideAppInitializer(() => {
      const translate = inject(TranslateService);
      registerAppTranslations(translate);
      translate.use('eng');
    }),
  ],
};

The feature registers its own reducer and effects when it loads, so provideStore and provideEffects have to exist before it. SharedModule brings the built-in translations and NgrxSharedModule connects the store the reducers are added to. MODEL_VALIDATORS_PROVIDER is asked for extra validators per field and has no default, so an application without custom rules still registers it and hands back the validators the field metadata already implies. The HTTP client is configured with interceptors from dependency injection, for a reason explained under Login. ROUTER_FEATURES and IN_MEMORY_API_PROVIDERS come from the two files the demo build replaces, as described under Try it hosted; in every other build they are the scrolling feature and an empty list.

The notes feature

One object says what the screens can do.

/**
 * Everything the generated list and item pages need to know about notes.
 * The columns, the form fields and the details view come from the `@Field`
 * decorators on `Note`; this object says which capabilities the screens have.
 */
export const notesConfig: CrudFullConfig<Note> = {
  // Relative, so the dev-server proxy (and any reverse proxy) can route it.
  apiUrl: '/api/notes',
  entity: 'notes',
  type: Note,
  title: 'Notes',
  add: true,
  edit: true,
  details: true,
  remove: true,
  search: true,
  pagination: { limit: 25 },
  sort: { default: 'title', defaultDesc: false },
  list: {
    mode: ListMode.desktop,
    paginationMode: PaginationMode.singlePage,
  },
};

The columns, the form fields and the details view are not listed here, because they come from the @Field decorators. The configuration adds the capabilities: which of add, edit, details and remove the screens offer, the search box, the page size and the default sort. apiUrl is relative, so the dev server proxy and any reverse proxy can route it.

The feature module registers that configuration and, with routing on, the three child routes.

/**
 * Registers the notes feature: the NgRx slice, the HTTP service and, because
 * `routing` is on, the three child routes ('' list, 'add' and ':id' item) the
 * application mounts under /notes.
 */
@NgModule({
  imports: [CrudModule.forFeature({ routing: true, config: notesConfig })],
})
export class NotesModule {}

The application mounts the feature under /notes behind a guard and sends every other path there.

export const appRoutes: Route[] = [
  { path: 'login', component: LoginPage },
  // `NotesModule` brings the generated routes: '' (list), 'add' and ':id'.
  {
    path: 'notes',
    canActivate: [authGuard],
    loadChildren: () => NotesModule,
  },
  { path: '', pathMatch: 'full', redirectTo: 'notes' },
  { path: '**', redirectTo: 'notes' },
];

Login

The login page is the framework's sign-in form and a small service around the token endpoint.

@Component({
  selector: 'app-login',
  imports: [SignInFormComponent],
  template: `
    <h1>Sign in</h1>
    <smart-sign-in-form
      [disabled]="pending()"
      [options]="options"
      (submit)="onSubmit($event)"
    />
    @if (error()) {
      <p role="alert" class="app-login__error">{{ error() }}</p>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginPage {
  private readonly loginService = inject(LoginService);
  private readonly router = inject(Router);

  protected readonly pending = signal(false);
  protected readonly error = signal<string | null>(null);

  /** The seeded user name is an email, so the form's email input fits. */
  protected readonly options: ISignInFormOptions = {
    showLabels: true,
    submitLabel: 'Sign in',
    emailPlaceholder: 'admin@example.com',
  };

  protected async onSubmit({
    email,
    password,
  }: ISignInFormSubmit): Promise<void> {
    this.pending.set(true);
    this.error.set(null);

    try {
      await this.loginService.signIn(email, password);
      await this.router.navigateByUrl('/notes');
    } catch (error) {
      this.error.set((error as Error).message);
    } finally {
      this.pending.set(false);
    }
  }
}

The service runs the password grant against /api/token and stores the token through AuthService, which is what the guard and the interceptor read.

/** Client registered in the API's `tokenConfig.clients`. */
export const AUTH_CLIENT_ID = 'example-app';
export const TOKEN_URL = '/api/token';

interface ITokenResponse {
  access_token: string;
  refresh_token: string;
  token_type: string;
  expired_in: number;
  username: string;
}

@Injectable({ providedIn: 'root' })
export class LoginService {
  private readonly http = inject(HttpClient);
  private readonly authService = inject(AuthService);

  /**
   * Runs the OAuth password grant against the API and stores the returned token.
   * Rejects with the API's `details` message (or a generic one) on failure.
   */
  async signIn(username: string, password: string): Promise<void> {
    try {
      const token = await firstValueFrom(
        this.http.post<ITokenResponse>(TOKEN_URL, {
          grant_type: 'password',
          username,
          password,
          client_id: AUTH_CLIENT_ID,
        }),
      );

      this.authService.setToken(token);
    } catch (error) {
      throw new Error(this.getErrorMessage(error));
    }
  }

  signOut(): void {
    this.authService.removeToken();
  }

  private getErrorMessage(error: unknown): string {
    const details =
      error instanceof HttpErrorResponse ? error.error?.details : null;

    return typeof details === 'string' ? details : 'Sign-in failed';
  }
}
/** Lets authenticated users through and sends everybody else to the login page. */
export const authGuard: CanActivateFn = () => {
  if (inject(AuthService).isAuthenticated()) return true;

  return inject(Router).createUrlTree(['/login']);
};

Every request to the API carries the token. The interceptor is registered as a class under HTTP_INTERCEPTORS rather than as a functional interceptor, and the reason is worth knowing: CrudModule.forFeature imports SharedModule, which re-exports HttpClientModule, so the lazily loaded notes route builds its own HttpClient. A functional interceptor registered at the root is not seen by that client. A class provided at the root still is.

/**
 * The same interceptor as a DI provider, which is the registration the app
 * uses. `CrudModule.forFeature` imports `SharedModule`, and `SharedModule`
 * re-exports `HttpClientModule`, so the lazily loaded notes route builds its
 * own `HttpClient`. Functional interceptors registered at the root are not
 * seen by that client, a class registered under `HTTP_INTERCEPTORS` at the
 * root still is.
 */
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private storage = inject(StorageService);

  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler,
  ): Observable<HttpEvent<unknown>> {
    return next.handle(withBearer(request, this.storage));
  }
}

export const AUTH_INTERCEPTOR_PROVIDER: Provider = {
  provide: HTTP_INTERCEPTORS,
  useClass: AuthInterceptor,
  multi: true,
};

Labels

The generated screens look up two kinds of label: the page title from the configuration and MODEL.<field> for every decorated field. The framework ships its own strings for buttons and validation messages under the same language codes.

/**
 * Labels the generated screens look up: the page title from `notesConfig.title`
 * and `MODEL.<field>` for every decorated field of `Note`. The framework ships
 * its own strings (buttons, validation) under the same two language codes.
 */
export const APP_TRANSLATIONS: Record<string, TranslationObject> = {
  eng: {
    Notes: 'Notes',
    MODEL: { title: 'Title', content: 'Content' },
  },
  pl: {
    Notes: 'Notatki',
    MODEL: { title: 'Tytuł', content: 'Treść' },
  },
};

export function registerAppTranslations(translate: TranslateService): void {
  Object.entries(APP_TRANSLATIONS).forEach(([lang, data]) => {
    translate.setTranslation(lang, data, true);
  });
}

Run it

The commands below are regions of docs/examples/app/run.sh, and each one is the body of a function that script runs, so the page and the script cannot disagree. You need Node.js, npm, Docker with Compose and the repository installed with npm ci at its root.

Start MongoDB and the API from the repository root.

# MongoDB and the API on http://localhost:3000/api
docker compose -f docs/examples/app/docker-compose.yml up

Start the frontend in a second terminal.

# The frontend on http://localhost:4200, proxying /api to the API
npx nx serve docs-examples-app-web

Open http://localhost:4200 and sign in with admin@example.com and change-me, the user the API seeds on its first start. You land on an empty list. Add opens the generated form, the arrow on a row opens the note read-only, Edit turns it into the form again and Remove asks for confirmation before deleting.

The dev server forwards /api to the API, so the frontend and the backend share one origin during development.

{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false
  }
}

The Jest suites cover the model, the API configuration and seed, and the login service, guard, interceptor and page.

# Jest: the model, the API services, the Angular services and pages
npx nx run-many -t test -p docs-examples-app-model docs-examples-app-api docs-examples-app-web

The Playwright suite drives the real stack: it builds and starts the API against MongoDB on localhost:27017, starts the dev server, and walks through login, the list with its confirm dialog, the item page and the API validation. The pull request workflow runs it with a MongoDB service.

# Playwright against MongoDB on localhost:27017; the API and the frontend are started for you
RUN_EXAMPLE_APP_E2E=1 npx nx test docs-examples-app-web-e2e

Try it hosted

The frontend is also published with this site, at https://framework.smartflow.biz.pl/demo/. Sign in with the same admin@example.com and change-me, and click through the list, the form and the item page.

Be clear about what you are looking at. GitHub Pages serves files only, so the demo does not run the API above. It runs against an in-memory double of the API that lives next to the application, in apps/web/src/app/in-memory: an HTTP interceptor that answers the requests the frontend makes, with the status codes and bodies the real API sends, and keeps the notes in the storage of your browser tab. Your notes never leave the browser, and a new tab starts from the seed again. Only the demo build registers the double, and the real backend is the docker compose up above. The Docs workflow runs the Playwright suite of this page against the hosted demo after every deploy.


Start from the template

The starter repository at https://github.com/emiljuchnikowski/smartsoft001-starter is this application as a workspace of its own, with the framework installed from npm instead of resolved through the workspace aliases. It is generated from docs/examples/app on every release, pinned to the @smartsoft001 packages of that release, and installed, built and tested from a clean clone before it is pushed, so it never drifts from the framework. Use "Use this template" on GitHub to create a repository of your own, or clone it and run npm install followed by ./run.sh up.


Where next

  • Architecture explains the layers this application is built on and follows one entity from its decorators to a screen.
  • CRUD documents every option of the configuration object and the generated pages.
  • Installation shows how to add the packages to a workspace of your own.