Add pages and routes

Each page of your app is an Angular component with a route in src/custom/routes.ts. Give every page an explicit route there, and protect member and admin pages with the core guards.

Add a page

  1. Create the component, such as src/custom/learn/learn.component.ts, with a default export:

    import { Component } from '@angular/core';
    
    @Component({
      standalone: true,
      template: `<h1>Learn</h1>`,
    })
    export default class LearnComponent {}
  2. Add its route to CUSTOM_ROUTES in src/custom/routes.ts:

    import type { Routes } from '@angular/router';
    
    export const CUSTOM_ROUTES: Routes = [
      { path: 'learn', loadComponent: () => import('./learn/learn.component').then((m) => m.default) },
    ];
  3. Start the app with npm run dev and open /learn.

The page takes the room between the message banner and the powered-by footer, even when its content is short. To fill that room, such as to center an empty state, make the page a flex column and give the part that should grow flex: 1. height: 100% inside the page does not reach the footer, because the shell has only a minimum height; a full-screen page (below) has a fixed height, where it does.

The shape of a route

CUSTOM_ROUTES is a plain Angular Routes list. The fields you use most:

FieldFor
pathThe address without the leading slash, such as 'learn' or 'learn/:lessonId'
loadComponentThe page, loaded only when someone opens it
titleThe page's name in the browser tab; Arc CMS adds the site's name after it (see Brand the admin)
canActivateGuards that decide who may open the page (below)
dataValues a guard reads, such as allowedRoles, and page options such as fullScreen (below) and titleKey, a translation key for the tab's title in the reader's language
childrenPages shown inside a layout, such as the admin shell

Core adds your routes after its own explicit routes, such as pricing and account, so those always win over yours and an app page cannot replace them by accident. Pick addresses no core page uses.

Your routes also come after the language routes (/{lang} and /{lang}/...), but they never wait for the site's languages: a first segment that one of your routes uses, such as pay in /pay or /pay/start, is never a language, so the page opens without reading Settings/localization. The language routes can only claim a first segment that is an enabled language code other than the default and that no route uses (Languages). Avoid language codes such as de or hi as your first segment: the site could then never publish that language under /de/. npm run dev and npm run build warn when one of your pages has such an address, and the Localization settings page refuses the code.

Who may open a page

Page forGuardImport fromSomeone not allowed goes to
AnyoneNone
Any signed-in personuserGuardsrc/app/pages/user/user.guards.ts/signup?redirect= and the page, then back to the page after signing in
Paying membersuserGuard, entitledGuardsrc/app/pages/user/user.guards.tsSign-up if signed out, /pricing if not a paying member (Add payments and premium features)
Some rolesroleGuard with data: { allowedRoles: [...] }src/app/guards/role.guard.ts/admin/unauthorized

Always give roleGuard a data.allowedRoles list. Without one, the guard lets every visitor open the page and only shows a warning.

A member page:

import { userGuard } from '../app/pages/user/user.guards';

export const CUSTOM_ROUTES: Routes = [
  {
    path: 'learn',
    canActivate: [userGuard],
    loadComponent: () => import('./learn/learn.component').then((m) => m.default),
  },
];

To keep the member menu around a member page, see Build the member area. Arc CMS's own member pages use memberPagesGuard instead, so an app can keep the accounts it manages out of them (App accounts); your pages with userGuard are not affected.

A guard only decides what the browser shows. The data a page reads is protected by security rules (Set security rules and indexes).

What the guards wait for

Every guard above waits for the signed-in person's users record, and nothing else. Arc CMS reads it once per sign-in and every guard and page shares that one read (AuthState.recordReady() in src/app/pages/(auth)/auth.store.ts), so three guards on a page still make one read.

Your own code can wait for the same record instead of reading it again. It is null when nobody is signed in:

import { inject } from '@angular/core';
import { AuthState } from '../app/pages/(auth)/auth.store';

const record = await inject(AuthState).recordReady();

initAuthStateListener() still works and uses the same read: it gives the record each time someone signs in or out, and starts no listener or query of its own.

Add an admin page

An admin page needs the admin shell (the side menu and layout) and the admin guard. Give it the same route shape core uses for its own admin pages:

  1. Create the component, such as src/custom/admin/lessons.component.ts:

    import { Component } from '@angular/core';
    import { TranslocoPipe } from '@jsverse/transloco';
    import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
    
    @Component({
      standalone: true,
      imports: [PageHeaderComponent, TranslocoPipe],
      template: `<arc-page-header [title]="'custom.lessons.title' | transloco"></arc-page-header>`,
    })
    export default class LessonsComponent {}
  2. Add the route, with the admin shell as the parent and your page as its child:

    import type { Routes } from '@angular/router';
    import { roleGuard } from '../app/guards/role.guard';
    
    export const CUSTOM_ROUTES: Routes = [
      {
        path: 'admin/lessons',
        loadComponent: () => import('../app/pages/admin.page').then((m) => m.default),
        canActivate: [roleGuard],
        data: { allowedRoles: ['admin'] },
        children: [
          { path: '', loadComponent: () => import('./admin/lessons.component').then((m) => m.default) },
        ],
      },
    ];
  3. Add the title to src/custom/i18n/en.json ("custom": { "lessons": { "title": "Lessons" } }) and link the page from the admin menu (Change the admin menu).

An admin page that has only a page file and no explicit route does not reliably reach the browser's route table. The address then falls through to the public content route and shows "Content Not Found" in the public layout. Firebase Hosting answers every address with the app shell and HTTP 200, so a status check does not catch it: open the page in a browser. Give every admin page an explicit route as above.

Keep components that have an explicit route outside src/custom/pages/, as above, or name them without .page. Every .page.ts file in that folder also gets an address of its own from its file name, without the admin shell, the guards of the explicit route, or a whenOn around it.

Pages found by file name

Files in src/custom/pages/ become routes by name, like the core pages in src/app/pages/:

FileAddress
src/custom/pages/learn.page.ts/learn
src/custom/pages/learn/index.page.ts/learn
src/custom/pages/learn/[lessonId].page.ts/learn/:lessonId
A folder in parentheses, such as src/custom/pages/(learning)/Groups files and adds nothing to the address

Two folders in parentheses side by side, such as src/custom/pages/(learning)/ and src/custom/pages/(practice)/, each become an empty-path route, and after a fresh start of npm run dev only one of them survives: the other page's address falls through to a later route. Give the second page a plain folder or an explicit route, and check new page files again after restarting the dev server.

A page file sets its title and guards with a routeMeta export:

import { RouteMeta } from '@analogjs/router';
import { userGuard } from '../../app/pages/user/user.guards';

export const routeMeta: RouteMeta = {
  title: 'Learn',
  canActivate: [userGuard],
};

A page file needs no entry in src/custom/routes.ts. Your explicit routes come before the file-based pages, so when both give the same address, the explicit route is used. Explicit routes remain the recommended way, and the only one for admin pages.

Pages that are never tracked

A page that must not be tracked by Google Analytics, such as a children's screen, says so on its route. Every page below that route inherits it:

{ path: 'kids', loadChildren: () => import('./kids/kids.routes').then((m) => m.KIDS_ROUTES), data: { analytics: false } }

On such a page collection is off from the moment the route is recognised, before the page shows, and it turns on again on the next tracked page. Nothing is reloaded. What was sent before the person reached the page stays sent, so keep personal details out of the addresses that lead there. See Analytics.

Full-screen pages

Around every page, Arc CMS shows the admin's message banner, the powered-by footer, the site usage (cookie) banner, the installed app's update bar and the feedback button. A game, a player, a kiosk or a presentation screen needs the whole screen instead. Say so on its route:

{ path: 'play', loadComponent: () => import('./play/play.component').then((m) => m.default), data: { fullScreen: true } }

On that page:

PartOn a full-screen page
Message banner, powered-by footer, feedback buttonNot shown
Site usage banner, update barHeld: they appear when the person reaches a normal page, so consent and updates still happen, just not over the screen. An app that has only full-screen pages shows the update itself: see Make it installable, which adds pwaUpdate: 'app' to the route's data. On such a page Arc CMS also never reloads to recover a screen whose code a deploy removed
LayoutThe page is exactly one screen tall (100dvh), with no page margin, padding or scroll, and no pull-to-refresh bounce. The page scrolls inside itself if it needs to.

Any route on the way to the page can set it, and the deepest one that sets it wins, so a child route can say fullScreen: false under a full-screen parent. The page's own top element fills the screen, so height: 100% inside it is the screen height. The feedback panel still opens from the app's own button (Feedback).

Core sets the class arc-full-screen on <html> and on arc-root while such a page is open, so styles in src/custom/styles.css can use it.

Keep the global styles out

Bootstrap, the Material theme and the icon fonts are loaded for the whole site. To keep them off a full-screen page, give its component Shadow DOM encapsulation:

import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  standalone: true,
  encapsulation: ViewEncapsulation.ShadowDom,
  styles: [`:host { display: block; }`],
  template: `...`,
})
export default class PlayComponent {}

Class rules such as btn or bi-star then do not reach inside the page; only inherited values such as the body font and color do. Load inside the component whatever the page needs, including any icon font.

Routes that belong to a feature

If a page only makes sense while a feature is on, wrap its routes so they go when the feature is off (Choose your app's features):

import { whenOn } from '../app/core/features/feature-routes';

export const CUSTOM_ROUTES: Routes = [
  ...whenOn('payments', [
    { path: 'learn/premium', loadComponent: () => import('./learn/premium.component').then((m) => m.default) },
  ]),
];