Title

NextJS Ordered Table Layout

This method uses CSS grid areas to arrange components across layouts and pages in the Next.js App Router.

Setup

Abstract

While working on my website, I had trouble keeping my components in the correct order and alignment while using the Next.js App Router. For example, I wanted different pages to have different navigation widgets, but I wanted those widgets to appear in consistent positions.

I also wanted to reuse components across layouts and pages. My solution was to define grid-template-areas on a shared grid container and assign components to those areas using grid-area. Components can be defined in different layout or page files, provided their rendered elements are direct children of that grid container.

This approach worked well for my website, so I thought I would share it for anyone dealing with a similar layout problem.

Basic Example

If you want to try and play around with this example, go ahead and go to CodeSandbox Example.

Let's start with a basic example in a plain HTML page. Then, we'll adapt it to a Next.js project. This is pretty basic, we have a grid container with three areas: header, main, and footer. The header and footer are full width, while the main content is centered. The main content is divided into two columns: a sidebar and a main content area. The sidebar is on the left, and the main content area is on the right.

In the code however, we can see that the footer is above the navigation and the main content area. with grid-template-areas. Proving we dont need to worry about the order of the elements in the HTML since they will be rendered in the order specified by the grid template in the css.

1<!DOCTYPE html>
2<html lang="en">
3<head>
4  <meta charset="UTF-8">
5  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6  <title>Grid Template Areas Example</title>
7
8  <style>
9    * {
10      box-sizing: border-box;
11    }
12
13    /* Our body will have the main grid container, and we will define the 
14    /* grid-template-areas for the layout. The header and footer will span 
15    /* the full width, while the sidebar and main content will be side by side.
16    */
17    body {
18      margin: 0;
19      min-height: 100vh;
20      display: grid;
21
22      grid-template-areas:
23        "header header"
24        "sidebar content"
25        "footer footer";
26
27      grid-template-columns: 220px 1fr;
28      grid-template-rows: auto 1fr auto;
29
30      font-family: Arial, sans-serif;
31      background: #f4f4f4;
32    }
33
34    /* Now we will assign each element to its respective grid area. */
35    /* The header will be at the top, spanning both columns. */
36    header {
37      grid-area: header;
38      padding: 24px;
39      background: #263238;
40      color: white;
41    }
42
43    /* The sidebar will be on the left, and will have a background color. */
44    nav {
45      grid-area: sidebar;
46      padding: 24px;
47      background: #e0e0e0;
48    }
49
50    nav a {
51      display: block;
52      margin-bottom: 16px;
53      color: #263238;
54    }
55
56    /* The main content will be on the right, and will have a white background. */
57    main {
58      grid-area: content;
59      min-width: 0;
60      padding: 24px;
61      background: white;
62    }
63
64    /* The footer will be at the bottom, spanning both columns. */
65    footer {
66      grid-area: footer;
67      padding: 20px;
68      background: #263238;
69      color: white;
70      text-align: center;
71    }
72
73    /* For smaller screens, we will stack the elements vertically. */
74    @media (max-width: 600px) {
75      body {
76        grid-template-areas:
77          "header"
78          "sidebar"
79          "content"
80          "footer";
81
82        grid-template-columns: 1fr;
83        grid-template-rows: auto auto 1fr auto;
84      }
85    }
86  </style>
87</head>
88
89<body>
90  <!-- The body already has the grid container, so we just need to assign the elements 
91  to their respective grid areas. -->  
92
93  <!-- The header will be at the top, spanning both columns. -->
94  <header>
95    <h1>My Website</h1>
96  </header>
97
98  <!-- The footer will be at the bottom, spanning both columns. -->
99  <footer>
100    My Website &copy; 2026
101  </footer>
102
103  <!-- The sidebar will be on the left, and will have a background color. -->
104  <nav aria-label="Main navigation">
105    <a href="#home">Home</a>
106    <a href="#about">About</a>
107    <a href="#contact">Contact</a>
108  </nav>
109
110  <!-- The main content will be on the right, and will have a white background. -->
111  <main>
112    <h2 id="home">Main Content</h2>
113    <p>This area fills the remaining width beside the navigation.</p>
114
115    <h2 id="about">About</h2>
116    <p>Each element is placed using its named grid area.</p>
117
118    <h2 id="contact">Contact</h2>
119    <p>hello@example.com</p>
120  </main>
121</body>
122</html>

Implementing in Next.js

The project can be found at Github! Or if you would like to play around with a live demo and make your own modifications go ahead and try out the demo at CodeSandbox! (which is shown below)

Now, our project will have a similar structure, but we will use React components instead of plain HTML elements. Our structure of our projects is roughly going to be:

lib/
└── styles.module.css       -- the grid container and one class per named area
components/
└── page-views/
    ├── PageViews.tsx       -- the views box each page places in the stats area
    └── styles.module.css   -- how the views box looks
app/
├── globals.css             -- base styles for the whole site
├── layout.tsx              -- the grid container, header, footer, navigation and filler
├── page.tsx                -- the home page's content area and page views
├── page.module.css         -- how the home page's content looks
├── about/
│   ├── page.tsx            -- the about page's content area and page views
│   └── page.module.css     -- how the about page's content looks
└── contact/
    ├── page.tsx            -- the contact page's content area and page views
    └── page.module.css     -- how the contact page's content looks

Next.js layouts wrap pages through their childrenprop, and Next.js doesn't wrap childrenin any extra elements. React fragments don't add any elements either. So when a page returns its pieces in a fragment, they become direct children of whatever element the layout renders children into. That is the whole trick: put the grid container in the root layout, and let every page drop its pieces straight into it.

Adding More to the Side

The Next.js project isn't a one-to-one copy of the HTML example. The HTML example only had four areas: the header, the sidebar, the content and the footer. This project adds two more to the left column: a stats area with a page views counter, and a fillerarea that stays empty. They are there to show that the side isn't limited to the navigation, and that we can keep adding pieces to it from any file.

HTML example                Next.js project
┌──────────────────────┐    ┌──────────────────────┐
│ header               │    │ header               │
├─────────┬────────────┤    ├─────────┬────────────┤
│ sidebar │ content    │    │ sidebar │ content    │
│         │            │    ├─────────┤            │
│         │            │    │ stats   │            │
│         │            │    ├─────────┤            │
│         │            │    │ filler  │            │
├─────────┴────────────┤    ├─────────┴────────────┤
│ footer               │    │ footer               │
└──────────────────────┘    └──────────────────────┘

The page views counter is the interesting one. The navigation comes from the root layout, but the counter comes from each page, and every page passes in its own numbers. Even so, it always lands right under the navigation, in the same spot on every page. That is exactly the problem from the abstract: different pages with different widgets, all lining up in consistent positions. A page that doesn't render a counter just leaves the stats area empty, and its row collapses to nothing.

The filler is there because of how grid rows work. When the content, or the page's minimum height of 100vh, is taller than the navigation and the counter, that extra height has to go to some row. Without the filler, the navigation and the counter would stretch to fill it. The filler's row is the only 1fr row, so it takes all of that extra height, and everything above it keeps its natural size.

Adding another widget to the side takes three small changes in lib/styles.module.css: a new name in grid-template-areas, a new size in grid-template-rows, and a class that sets grid-area. After that, any layout or page can render an element with that class. For example, here is a tags area added between the counter and the filler:

1.grid {
2  min-height: 100vh;
3  display: grid;
4
5  grid-template-areas:
6    "header header"
7    "sidebar content"
8    "stats content"
9    "tags content"
10    "filler content"
11    "footer footer";
12
13  grid-template-columns: 220px 1fr;
14  grid-template-rows: auto auto auto auto 1fr auto;
15}
16
17/* Any element with this class lands between the stats and the filler,
18/* no matter which layout or page renders it. */
19.tags {
20  grid-area: tags;
21}

Moving things around is just as easy. Since every element is placed by name, grid-template-areas is the only thing that decides where it goes. To give the counter its own column on the right side of the page, add a third column to the template, and none of the components have to change:

1.grid {
2  min-height: 100vh;
3  display: grid;
4
5  /* The stats now get their own column on the right. */
6  grid-template-areas:
7    "header header header"
8    "sidebar content stats"
9    "filler content stats"
10    "footer footer footer";
11
12  grid-template-columns: 220px 1fr 220px;
13  grid-template-rows: auto auto 1fr auto;
14}

The Grid Container

lib/styles.module.css

This is the heart of the project. The .grid class is the shared grid container, and its grid-template-areas lay out six named areas. The header and footer span the full width, while the sidebar, stats and filler stack up in a 220px column beside the content area.

Every area also gets its own class (.header, .sidebar, .stats, .filler, .content and .footer) that sets its grid-area. Any component that wants to sit in an area just adds that class, no matter which file it lives in.

grid-template-rows needs one size per row. The filler row is the only 1fr row in the left column, so it soaks up the leftover height, and the navigation and stats keep their natural size instead of stretching to match the content. Below 600px, the areas stack into a single column and the filler is hidden, since there is no leftover height to fill.

1/* The shared grid container. It defines the grid-template-areas for the
2/* whole app. The header and footer span the full width, while the left
3/* column (sidebar, stats and filler) sits beside the content area.
4/*
5/* Anything that should land in one of these areas must be a direct child of
6/* this element, no matter which layout or page file renders it.
7*/
8.grid {
9  min-height: 100vh;
10  display: grid;
11
12  grid-template-areas:
13    "header header"
14    "sidebar content"
15    "stats content"
16    "filler content"
17    "footer footer";
18
19  grid-template-columns: 220px 1fr;
20  /* One size per row: the filler row takes the leftover height, so the
21  /* sidebar and stats stay their natural size at the top of the column. */
22  grid-template-rows: auto auto auto 1fr auto;
23}
24
25/* Now each area gets a class that components can opt into. */
26/* The header will be at the top, spanning both columns. */
27.header {
28  grid-area: header;
29  padding: 24px;
30  background: #263238;
31  color: white;
32}
33
34.header h1 {
35  margin: 0;
36}
37
38/* The sidebar will be on the left, and will have a background color. */
39.sidebar {
40  grid-area: sidebar;
41  padding: 24px;
42  background: #e0e0e0;
43}
44
45.sidebar a {
46  display: block;
47  margin-bottom: 16px;
48  color: #263238;
49}
50
51/* The stats sit under the sidebar. Pages that want stats render their own
52/* element with this class. */
53.stats {
54  grid-area: stats;
55  padding: 24px;
56  background: #e0e0e0;
57  border-top: 1px solid #cfd8dc;
58}
59
60/* The filler has nothing inside. It soaks up the rest of the left column so
61/* the sidebar and stats don't stretch to match the content's height. */
62.filler {
63  grid-area: filler;
64  background: #e0e0e0;
65}
66
67/* The content area will be on the right, and will have a white background.
68/* Each page renders its own <main> with this class. */
69.content {
70  grid-area: content;
71  min-width: 0;
72  padding: 24px;
73  background: white;
74}
75
76/* The footer will be at the bottom, spanning both columns. */
77.footer {
78  grid-area: footer;
79  padding: 20px;
80  background: #263238;
81  color: white;
82  text-align: center;
83}
84
85/* For smaller screens, we will stack the areas vertically. */
86@media (max-width: 600px) {
87  .grid {
88    grid-template-areas:
89      "header"
90      "sidebar"
91      "stats"
92      "content"
93      "footer";
94
95    grid-template-columns: 1fr;
96    grid-template-rows: auto auto auto 1fr auto;
97  }
98
99  /* Once everything stacks there is no leftover height to fill. */
100  .filler {
101    display: none;
102  }
103}

Global Styles

app/globals.css

The base styles for the whole site, the same ones from the HTML example: border-box sizing, no margin on the body, and the font and background color.

1* {
2  box-sizing: border-box;
3}
4
5body {
6  margin: 0;
7  font-family: Arial, sans-serif;
8  background: #f4f4f4;
9}

The Root Layout

app/layout.tsx

The root layout renders the grid container along with the pieces every page shares: the header, the footer, the navigation and the empty filler. Each one gets its area class from lib/styles.module.css.

Just like the HTML example, the footer is written before the navigation and the page, and the grid still puts it at the bottom. Then children is rendered as the last child of the grid container, which is where whatever the current page returns ends up.

One difference from the HTML example is that the grid lives on a wrapper <div> instead of <body>. Next.js adds its own elements to <body>, such as scripts, a hidden <div> and a <next-route-announcer>. If <body> were the grid, the route announcer would become an extra grid item and add a row to the layout.

1import type { Metadata } from "next";
2import Link from "next/link";
3import grid from "@/lib/styles.module.css";
4import "./globals.css";
5
6export const metadata: Metadata = {
7  title: {
8    default: "My Website",
9    template: "%s | My Website",
10  },
11  description: "Grid template areas in the Next.js App Router",
12};
13
14export default function RootLayout({ children }: LayoutProps<"/">) {
15  return (
16    <html lang="en">
17      <body>
18        {/* The grid container lives on a wrapper rather than <body>, so nothing
19        that Next.js or a browser extension injects into <body> can end up as
20        a stray grid item. */}
21        <div className={grid.grid}>
22          {/* The header will be at the top, spanning both columns. */}
23          <header className={grid.header}>
24            <h1>My Website</h1>
25          </header>
26
27          {/* The footer is written above the navigation and the page, but the
28          grid template still places it at the bottom. */}
29          <footer className={grid.footer}>My Website &copy; 2026</footer>
30
31          {/* The sidebar will be on the left. */}
32          <nav className={grid.sidebar} aria-label="Main navigation">
33            <Link href="/">Home</Link>
34            <Link href="/about">About</Link>
35            <Link href="/contact">Contact</Link>
36          </nav>
37
38          {/* The filler is empty. It fills the rest of the left column. */}
39          <div className={grid.filler} />
40
41          {/* Each page renders its own <main className={grid.content}> and
42          <PageViews>, which fill the content and stats areas. Both become
43          direct children of the grid container right here. */}
44          {children}
45        </div>
46      </body>
47    </html>
48  );
49}

The Home Page

app/page.tsx

The home page returns a fragment with two elements: its <main> content and a <PageViews> box. Both become direct children of the grid container. <main> lands in the content area, and the views box lands in the stats area, right under the navigation from the layout.

The <main> element combines two classes: grid.content places it, and styles.mainfrom the page's own CSS module styles it.

1import PageViews from "@/components/page-views/PageViews";
2import grid from "@/lib/styles.module.css";
3import styles from "./page.module.css";
4
5export default function Home() {
6  return (
7    <>
8      <main className={`${grid.content} ${styles.main}`}>
9        <h2>Main Content</h2>
10        <p>This area fills the remaining width beside the navigation.</p>
11        <p>
12          It is rendered by <code>app/page.tsx</code>, but it lands in the{" "}
13          <code>content</code> area defined in <code>lib/styles.module.css</code>,
14          even though the layout renders it after the footer.
15        </p>
16        <p>
17          The views box under the navigation comes from <code>app/page.tsx</code>{" "}
18          too. It lands in the <code>stats</code> area, between the navigation
19          and the filler that the layout renders.
20        </p>
21      </main>
22
23      <PageViews today={12} total={340} />
24    </>
25  );
26}

app/page.module.css

This file only handles how the home page looks. Where it goes comes from lib/styles.module.css, so a page's CSS module never has to know about the grid.

1/* Styles for the home page's content area. The grid placement comes from
2/* lib/styles.module.css; this file only handles how the page looks. */
3.main h2 {
4  margin-top: 0;
5}
6
7.main code {
8  padding: 2px 4px;
9  background: #eceff1;
10  border-radius: 4px;
11}

The Page Component

A page component is any component a page renders that should land in its own grid area instead of the content area. This is the kind of widget that started all of this: each page renders it itself, with its own data, but it should show up in the same spot on every page.

The trick is that the component carries its own area class. It puts the class on its outermost element, so any page that renders it gets it placed in that area automatically, and the page doesn't have to know anything about the grid. The same pattern works for any widget: a tags list, a related posts box, or a table of contents.

components/page-views/PageViews.tsx

The example project's page component is a views box. It takes the page's today and total view counts as props, and shows them alongside the views for the whole site. It adds the grid.stats class to its own <aside>, which puts it in the stats area. The numbers are hardcoded examples. A real site would read them from its analytics.

1import grid from "@/lib/styles.module.css";
2import styles from "./styles.module.css";
3
4type Views = {
5  today: number;
6  total: number;
7};
8
9// Example numbers. A real site would read these from its analytics.
10const site: Views = { today: 58, total: 2914 };
11
12// Each page renders this with its own numbers. The grid.stats class places it
13// in the stats area under the layout's navigation.
14export default function PageViews({ today, total }: Views) {
15  const rows = [
16    { label: "Page", today, total },
17    { label: "Site", ...site },
18  ];
19
20  return (
21    <aside className={`${grid.stats} ${styles.stats}`} aria-label="Page views">
22      <h3>Views</h3>
23      <div className={styles.row}>
24        <span />
25        <span>Today</span>
26        <span>Total</span>
27      </div>
28      {rows.map(({ label, today, total }) => (
29        <div key={label} className={styles.row}>
30          <span>{label}</span>
31          <span>{today.toLocaleString("en-US")}</span>
32          <span>{total.toLocaleString("en-US")}</span>
33        </div>
34      ))}
35    </aside>
36  );
37}

components/page-views/styles.module.css

Lays out each row of the views box with flexbox. The label takes up the remaining space, and the two numbers sit in fixed-width columns on the right.

1/* The page views box in the stats area. Grid placement comes from
2/* lib/styles.module.css. */
3.stats h3 {
4  margin: 0 0 12px;
5}
6
7.row {
8  display: flex;
9  gap: 12px;
10  padding: 4px 0;
11}
12
13.row span:first-child {
14  flex: 1;
15}
16
17.row span:not(:first-child) {
18  width: 48px;
19  text-align: right;
20  font-variant-numeric: tabular-nums;
21}

The About Page

app/about/page.tsx

The about page works the same way as the home page, with its own content and its own view counts. Its content lists each element on the page, the grid area it lands in and the file that renders it, which shows that the pieces really do come from different files. It also sets its own metadata title.

1import type { Metadata } from "next";
2import PageViews from "@/components/page-views/PageViews";
3import grid from "@/lib/styles.module.css";
4import styles from "./page.module.css";
5
6export const metadata: Metadata = {
7  title: "About",
8};
9
10const areas = [
11  { element: "<header>", area: "header", file: "app/layout.tsx" },
12  { element: "<nav>", area: "sidebar", file: "app/layout.tsx" },
13  { element: "<aside>", area: "stats", file: "app/about/page.tsx" },
14  { element: "<div>", area: "filler", file: "app/layout.tsx" },
15  { element: "<main>", area: "content", file: "app/about/page.tsx" },
16  { element: "<footer>", area: "footer", file: "app/layout.tsx" },
17];
18
19export default function About() {
20  return (
21    <>
22      <main className={`${grid.content} ${styles.main}`}>
23        <h2>About</h2>
24        <p>Each element is placed using its named grid area.</p>
25
26        <ul className={styles.areas}>
27          {areas.map(({ element, area, file }) => (
28            <li key={area} className={styles.area}>
29              <code className={styles.element}>{element}</code>
30              <span>grid-area: {area}</span>
31              <span className={styles.file}>{file}</span>
32            </li>
33          ))}
34        </ul>
35      </main>
36
37      <PageViews today={5} total={128} />
38    </>
39  );
40}

app/about/page.module.css

Lays out the list of areas as cards with flexbox. The cards wrap onto new lines as the content area gets narrower.

1/* Styles for the about page's content area. */
2.main h2 {
3  margin-top: 0;
4}
5
6/* The cards wrap onto new lines as the content area gets narrower. */
7.areas {
8  display: flex;
9  flex-wrap: wrap;
10  gap: 16px;
11  margin: 0;
12  padding: 0;
13  list-style: none;
14}
15
16.area {
17  display: flex;
18  flex-direction: column;
19  gap: 6px;
20  flex: 1 1 160px;
21  padding: 12px 16px;
22  background: #eceff1;
23  border-left: 4px solid #263238;
24  border-radius: 4px;
25}
26
27.element {
28  font-size: 1.1rem;
29  font-weight: bold;
30}
31
32.file {
33  font-family: monospace;
34  color: #546e7a;
35}

The Contact Page

app/contact/page.tsx

The simplest page: an email link in the content area, and its own view counts in the stats area.

1import type { Metadata } from "next";
2import PageViews from "@/components/page-views/PageViews";
3import grid from "@/lib/styles.module.css";
4import styles from "./page.module.css";
5
6export const metadata: Metadata = {
7  title: "Contact",
8};
9
10export default function Contact() {
11  return (
12    <>
13      <main className={`${grid.content} ${styles.main}`}>
14        <h2>Contact</h2>
15        <p>
16          <a className={styles.email} href="mailto:hello@example.com">
17            hello@example.com
18          </a>
19        </p>
20      </main>
21
22      <PageViews today={2} total={57} />
23    </>
24  );
25}

app/contact/page.module.css

Styles the email link.

1/* Styles for the contact page's content area. */
2.main h2 {
3  margin-top: 0;
4}
5
6.email {
7  font-size: 1.25rem;
8  color: #263238;
9}

Running the Project

The project is built with Next.js 16 and React 19, and is styled with CSS Modules only. To try it yourself, clone the repository, install the dependencies and start the dev server, then open http://localhost:3000.

1git clone https://github.com/Lrios4403/NextJS-Ordered-Table-Layout.git
2cd NextJS-Ordered-Table-Layout
3bun install
4bun dev