Skip to content

chore: Rework router structure#51

Merged
maanlamp merged 25 commits into
mainfrom
chore/change-router-structure
May 6, 2026
Merged

chore: Rework router structure#51
maanlamp merged 25 commits into
mainfrom
chore/change-router-structure

Conversation

@maanlamp
Copy link
Copy Markdown
Collaborator

Closes #50

TLDR

Removed split between src/routes and src/pages.

What changed

Route structure: Dot-notation files (_auth.login.tsx etc.) replaced with a proper nested folder structure.

src/pages removed. Logic lives in route.tsx files now.

src/layouts removed. Inlined as -layout.tsx / -header.tsx under their respective route folders.

src/router folded into src/lib. Router setup is in src/lib/router.tsx. getRouteApi(...) is gone.

Cookie-based auth + React Query preload. Auth lives in the router. _app/route.tsx uses queryClient.ensureQueryData() in beforeLoad to check the current user via cookie before any protected route renders. On failure, redirects to /login with the original path as a redirect param. Mock API updated to support this.

Supporting changes

  • .gitattributes: added eol=lf to actually enforce LF on checkout (text=auto alone doesn't do it)
  • biome.json: tightened ignore pattern from **/*.d.ts to **/*.module.scss.d.ts
  • Updated openapi.json with new / improved requests & responses.
  • Updates to config files to make the editor shut up :)
  • api.tsx -> api.ts No JSX in a non-component file. Fake token removed in favour of fake API response with real cookies.

@maanlamp maanlamp requested a review from publicJorn as a code owner April 29, 2026 14:23
@maanlamp
Copy link
Copy Markdown
Collaborator Author

I'm not sure how I feel about the remaining -page.module.scss and corresponding -page.module.scss.d.ts files 🤔...

Also note that I think my buddy Claude removed/rewrote some code and or comments that I didn't instruct it to touch. Might have to do a second sweep if anyone feels like it was too rigourous.

@Atchferox
Copy link
Copy Markdown
Contributor

Atchferox commented Apr 29, 2026

I think the overal idea of this pr implements our issue, but i do think keeping src/layouts or moving them to src/components/layouts, is a better idea, we didn't want the "-" notation in our file routes

also seeing it this way makes me realize i didn't think it through where the page css should be located, if page logic lives in routes folder

Comment thread src/components/header/app-header.tsx
@maanlamp
Copy link
Copy Markdown
Collaborator Author

maanlamp commented Apr 30, 2026

After some thinking, we can solve this in a few ways:

  1. Pages all share the same grid layout class, we could make a single page.module.scss and reference that.
  2. Since pages only have custom style to do layout, we could create a shared layout module in the style folder, and export classes from there. Similar to 1 but with more functionality, such as classes for padding/flex using design tokens.
  3. Make better use of the already existing layouts, to export those classes instead (or automatically put everything in the right place).

Any one of these would fix both the issue of duplicating style, and having -whatever.module.scss and -whatever.module.scss.d.ts everywhere.

There is of course also CSS-in-JS which would solve colocation in another way, but I'm not sure if that's an approach we'd like to explore.

@Atchferox
Copy link
Copy Markdown
Contributor

There is of course also CSS-in-JS which would solve colocation in another way, but I'm not sure if that's an approach we'd like to explore.

I think this will lead to another learning curve, imo if we choose to go a direction where we don't need separate css files we should move to tailwind v4 👀

@Atchferox
Copy link
Copy Markdown
Contributor

Atchferox commented May 1, 2026

Building on the options above — a fourth: push page-level styling into a <Page> compound component in src/components/layout/page/. Route files contain only routing + content, no .module.scss neighbour, so the - prefix isn't needed because there are no non-route files to hide.

Route files

// src/routes/_app/index/route.tsx
import { createFileRoute } from "@tanstack/react-router";
import { Page, PageTitle } from "components/layout/page";
import { useTranslation } from "react-i18next";

export const Route = createFileRoute("/_app/")({
	component: HomePage,
});

function HomePage() {
	const { t } = useTranslation();
	return (
		<Page>
			<PageTitle>{t("pages.home.title")}</PageTitle>
		</Page>
	);
}
// src/routes/_auth/forgot-password/route.tsx
import { createFileRoute } from "@tanstack/react-router";
import { Page, PageTitle } from "components/layout/page";
import { useTranslation } from "react-i18next";

export const Route = createFileRoute("/_auth/forgot-password")({
	component: ForgotPasswordPage,
});

function ForgotPasswordPage() {
	const { t } = useTranslation();
	return (
		<Page align="center">
			<PageTitle>{t("pages.forgotPassword.title")}</PageTitle>
		</Page>
	);
}

No -foo.module.scss, no .d.ts, no useDocumentTitle in the route — it's coupled to <PageTitle> where it belongs. Page markup stays in the route; only the styling primitives are abstracted out.

The component

// src/components/layout/page/index.tsx
import { useDocumentTitle } from "@uidotdev/usehooks";
import { cva, type VariantProps } from "class-variance-authority";
import classNames from "classnames";
import { H1 } from "components/heading/heading";
import type { ComponentProps, ReactNode } from "react";
import style from "./page.module.scss";

const pageVariants = cva(style.page, {
	variants: {
		align: {
			left: style.alignLeft,
			center: style.alignCenter,
		},
		width: {
			main: style.widthMain,
			full: style.widthFull,
		},
	},
	defaultVariants: {
		align: "left",
		width: "main",
	},
});

type PageProps = ComponentProps<"div"> & VariantProps<typeof pageVariants>;

function Page({ align, width, className, ...rest }: PageProps) {
	return (
		<div
			className={classNames(pageVariants({ align, width }), className)}
			{...rest}
		/>
	);
}

function PageTitle({ children }: { children: string }) {
	useDocumentTitle(children);
	return <H1 size="medium">{children}</H1>;
}

function PageHeader({ children }: { children: ReactNode }) {
	return <header className={style.header}>{children}</header>;
}

function PageSection({ children }: { children: ReactNode }) {
	return <section className={style.section}>{children}</section>;
}

export { Page, PageTitle, PageHeader, PageSection };
// src/components/layout/page/page.module.scss
.page {
	display: flex;
	flex-direction: column;
	gap: var(--unit-8);
}

.widthMain { grid-column: main; }
.widthFull { grid-column: 1 / -1; }

.alignLeft   { text-align: left; }
.alignCenter { text-align: center; }

.header {
	display: flex;
	justify-content: space-between;
	align-items: center;
	gap: var(--unit-4);
}

.section {
	display: flex;
	flex-direction: column;
	gap: var(--unit-4);
}

Composes for richer pages without boolean props

function UsersPage() {
	const { t } = useTranslation();
	return (
		<Page>
			<PageHeader>
				<PageTitle>{t("pages.users.title")}</PageTitle>
				<Button>{t("pages.users.create")}</Button>
			</PageHeader>
			<PageSection>
				<UserList />
			</PageSection>
		</Page>
	);
}

The full page markup lives in the route, just expressed in layout primitives instead of bespoke divs and SCSS.

Why

  • No SCSS in src/routes/ — the - prefix exists to hide colocated non-route files; remove the colocation, remove the need.
  • Single source of layout truth — grid placement (grid-column: main) lives in one place, not duplicated across pages.
  • Variants beat duplicated rulesalign, width, padding etc. are CVA variants: type-safe, autocompleted, one-line to add when a pattern repeats.
  • Escape hatch without leaving the routeclassName still passes through for true one-offs; the page itself keeps composing in route.tsx instead of getting hidden behind a wrapper component.
  • Open for context later — if page parts ever need shared state (sticky-header, breadcrumbs, scroll restoration), wrap in a provider; no API change for callers.

Adds class-variance-authority (~1 kB gz, no peer deps). Same shape works without it using a hand-rolled classNames helper — CVA just gives free typed variant inference.

@publicJorn
Copy link
Copy Markdown
Member

I thought about it a bit and played around with some options.

Let me start with the basic approach of colocating. We said that we don't want - in the file names, but it is an option.
It is possible to configure stuff, like excluding all *.scss* files from routing so that we don't need a - in front of it.
Also when applying a - to a subdir, you don't need to prepend the minus in front of all containing files. So stuff like this becomes possible:

_auth/
  -layout/           <-- all files within will be ignored
    auth-layout.module.scss
    auth-layout.tsx
    header.tsx       <-- auth-layout uses this (buuut, really should be put in src/components/header)
  login.module.scss  <-- ignored by configuration
  login.tsx          <-- uses login.module.scss and possibly auth-layout.module.scss for layout
  route.tsx          <-- imports -layout/auth-layout.tsx

This has the improvement that there are less - files/folders. But the routes folder still looks a bit messy to me.

Side note: I'm not a great fan of adding a sub-folder for each leaf route, because we'll end up with many route.tsx and/or index.tsx files, which make it slightly confusing which one you have or need open. But if you guys really want it, I could see it through the fingers 🙂


The second option is splitting the layout from the routes folder.
Luuk's compound components are an option. But it feels like a convention with soft boundaries. I'm affraid these components will grow large and get many exceptions. It will quickly grow with variants of the page, but also in sub-components. And since styles are coupled with the components they will boost each other.

We could just recreate the src/layouts folder and place layout components there with colocated style files. Then use simple css modules to get shared stuff into the pages.
But it begs the question: why treat layouts different from pages?

Wether we use compound layouts, or layout files; Pages will still need an exception sometimes. Sometimes only one or a couple of class names. Which will require a css module. Or sub components. Where do we put it?
This is why initially I created the distinction between the routes and pages folders.


Let's have a live chat about this and cut the knot ✂️. Maybe have a tryout project where we do use option 1?

Comment thread src/lib/api.ts
// TODO: THIS IS FAKE API HANDLING TO MAKE THE TEMPLATE
// WORK, PLEASE REMOVE THIS AND SET YOUR PROXY TO YOUR
// ACTUAL BACKEND.
client.interceptors.response.use(async (res) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea this!

@publicJorn
Copy link
Copy Markdown
Member

publicJorn commented May 5, 2026

src/
  components/
    header/
  layouts/
    auth-layout/
      auth-layout.module.scss
      auth-layout.tsx
  routes/
    _auth/
      login.module.scss
      login.tsx
      route.tsx    

We exclude the *.module.scss* from routes folder (d.ts file we still need to discuss separately #60)

@maanlamp
Copy link
Copy Markdown
Collaborator Author

maanlamp commented May 5, 2026

Implemented the discussed changes.

Changes

  • Router config: added routeFileIgnorePattern: "\\.module\\.scss$".module.scss files never matched as routes
  • Routes flattened: directory-based routes (login/route.tsx) replaced with flat files (login.tsx) alongside their .module.scss
  • Layouts extracted: moved to src/layouts/<name>/<name>.tsx, imported explicitly in each route.tsx
  • Components hoisted: route-level components moved to src/components/, grouped by type
    (header/app-header/)
  • Biome: VCS integration enabled (useIgnoreFile: true), redundant explicit excludes removed in favour of .gitignore

Route structure

  src/
  ├── components/
  │   └── header/
  │       └── app-header/
  │           └── app-header.tsx
  ├── layouts/
  │   ├── app-layout/
  │   │   └── app-layout.tsx
  │   └── auth-layout/
  │       └── auth-layout.tsx
  └── routes/
      ├── __root.tsx
      ├── not-found.module.scss
      ├── _app/
      │   ├── route.tsx          → AppLayout
      │   ├── index.tsx
      │   └── index.module.scss
      └── _auth/
          ├── route.tsx          → AuthLayout
          ├── login.tsx
          ├── login.module.scss
          ├── forgot-password.tsx
          └── forgot-password.module.scss

@maanlamp maanlamp requested review from Atchferox and publicJorn May 5, 2026 13:30
Copy link
Copy Markdown
Member

@publicJorn publicJorn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very nice and clean! Some small adjustments and it's ready.

Comment thread src/components/header/app-header.tsx
Comment thread package.json Outdated
Comment thread src/routes/_app/index.tsx Outdated
@maanlamp maanlamp merged commit 2b1cda5 into main May 6, 2026
1 check passed
@maanlamp maanlamp deleted the chore/change-router-structure branch May 6, 2026 09:17
publicJorn pushed a commit that referenced this pull request May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify route ownership: remove src/pages and adopt folder-based route modules in src/routes

3 participants