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
| Concern | Recommendation |
|---|---|
| Business logic | Keep fetch calls in services/ modules |
| Routes | One router file (App.tsx or router.tsx) |
| Pages | Export loader/action from the page file; keep UI in components |
| Components | Pure UI, receiving data from useLoaderData or props |
| Errors | errorElement at the root; more specific ones where useful |
| 404 | Always include { path: '*' } |
| Links | Relative paths in nested routes; NavLink + end for exact matches |
| Pending UX | useNavigation in the root layout |
| Shareable state | Query params via useSearchParams |
Code compare
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.tsxsrc/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.tsComplete 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 }, ], }, ], },];