Building a Multi-Page SPA with React RouterEntry 19 of 20

Organizing a Router Project

A folder structure and set of conventions that keep routes, pages, and data logic separate.

Notes

ConcernRecommendation
Business logicKeep fetch calls in services/ modules
RoutesOne router file (App.tsx or router.tsx)
PagesExport loader/action from the page file; keep UI in components
ComponentsPure UI, receiving data from useLoaderData or props
ErrorserrorElement at the root; more specific ones where useful
404Always include { path: '*' }
LinksRelative paths in nested routes; NavLink + end for exact matches
Pending UXuseNavigation in the root layout
Shareable stateQuery params via useSearchParams

Code compare

React
src/├── App.tsx                 // createBrowserRouter + RouterProvider├── services/│   ├── events.ts           // getEvents, getEvent, saveEvent, deleteEvent│   └── newsletter.ts├── actions/│   └── event-form-action.ts├── pages/│   ├── Root.tsx            // layout + useNavigation│   ├── Error.tsx│   ├── Home.tsx│   ├── EventsRoot.tsx│   ├── Events.tsx          // + loader│   ├── EventDetail.tsx     // + loader, delete action│   ├── NewEvent.tsx│   ├── EditEvent.tsx│   └── Newsletter.tsx      // + action└── components/    ├── MainNavigation.tsx    ├── EventsList.tsx    ├── EventItem.tsx    ├── EventForm.tsx    └── NewsletterSignup.tsx
Angular
src/app/├── app.config.ts           // provideRouter(routes, withComponentInputBinding(), ...)├── app.routes.ts├── core/│   ├── layout/root-layout.component.ts│   ├── error/error.component.ts│   └── not-found/not-found.component.ts├── services/│   ├── events.service.ts│   └── newsletter.service.ts└── events/    ├── events.routes.ts    // lazy-loaded with loadChildren    ├── events.resolver.ts    ├── event.resolver.ts    ├── events.component.ts    ├── event-detail.component.ts    ├── edit-event.component.ts    ├── new-event.component.ts    └── event-form.component.ts

Complete route tree

// App.tsxconst router = createBrowserRouter([  {    path: "/",    element: <RootLayout />,    errorElement: <ErrorPage />,    children: [      { index: true, element: <HomePage /> },      {        path: "events",        element: <EventsRootLayout />,        children: [          { index: true, element: <EventsPage />, loader: eventsLoader },          {            path: ":eventId",            id: "event-detail",            loader: eventDetailLoader,            children: [              { index: true, element: <EventDetailPage />, action: deleteEventAction },              { path: "edit", element: <EditEventPage />, action: manipulateEventAction },            ],          },          { path: "new", element: <NewEventPage />, action: manipulateEventAction },        ],      },      { path: "newsletter", element: <NewsletterPage />, action: newsletterAction },      { path: "*", element: <NotFoundPage /> },    ],  },]);
// app.routes.tsexport const routes: Routes = [  {    path: "",    component: RootLayoutComponent,    children: [      { path: "", component: HomeComponent, pathMatch: "full" },      {        path: "events",        loadChildren: () => import("./events/events.routes").then((m) => m.EVENTS_ROUTES),      },      { path: "newsletter", component: NewsletterComponent },      { path: "error", component: ErrorComponent },      { path: "**", component: NotFoundComponent },    ],  },];
// events/events.routes.tsexport const EVENTS_ROUTES: Routes = [  {    path: "",    component: EventsRootLayoutComponent,    children: [      { path: "", component: EventsComponent, resolve: { events: eventsResolver } },      { path: "new", component: NewEventComponent },      {        path: ":eventId",        resolve: { event: eventResolver },        children: [          { path: "", component: EventDetailComponent },          { path: "edit", component: EditEventComponent },        ],      },    ],  },];