#Higher Order Function for Shared Page Layouts

1 messages · Page 1 of 1 (latest)

noble mountain
#

It's not possible to share props between a layout and any children of that layout. This was an issue I ran into while I was working on my personal site that I had to find a solution for since I have two different pages that look exactly the same except for the text.

At first, I though I could just extract the logic into a shared layout and then pass the specific text information into layout, but this isn't allowed in Next. So I had to come up with a way of re-using this layout. At first I just extracted all of the relevant logic into a component and passed the specific text content as a prop, like so:

#
/**
 * A component that renders an entire page with a grid of cards.
 * Meant to be used as the only export from a Next.js page route.
 * @param title The title of the page
 * @param description The description of the page
 * @param cardContent The content of the cards
 */
export function CardGridPage({ title, description, cardContent = [] }: CardGridPageProps) {
  return (
    <SimpleLayout
      title={title}
      intro={description}
    >
      <ul
        role="list"
        className="grid grid-cols-1 gap-x-12 gap-y-16 sm:grid-cols-2 lg:grid-cols-3"
      >
        {cardContent.map((project) => (
          <Card as="li" key={project.name}>
            {typeof project.logo !== "undefined" && (
              <div className="mb-6 relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0">
                <Image
                  src={project.logo}
                  alt=""
                  className="h-8 w-8"
                  unoptimized
                />
              </div>
            )}

            <h2 className="text-base font-semibold text-zinc-800 dark:text-zinc-100">
              <Card.Link href={project.link.href}>{project.name}</Card.Link>
            </h2>

            <Card.Description>{project.description}</Card.Description>

            <p className="relative z-10 mt-6 flex text-sm font-medium text-zinc-400 transition group-hover:text-teal-500 dark:text-zinc-200">
              <LinkIcon className="h-6 w-6 flex-none" />

              <span className="ml-2">{project.link.label}</span>
            </p>
          </Card>
        ))}
      </ul>
    </SimpleLayout>
  )
}
#

Then I use this component like so in /projects/page.jsx:

import { type Metadata } from 'next'
import Image from 'next/image'

import { Card } from '@/components/Card'
import { SimpleLayout } from '@/components/SimpleLayout'
import logoMainStreetData from '@/images/logos/logoMainStreetData.jpeg'
import logoArtisus from '@/images/logos/logoArtisus.png'
import logoStrongForceSolutions from '@/images/logos/logoStrongForceSolutions.png'
import { CardGridPage } from '../_components/CardGridPage'

const projects = [
  {
    name: 'Main Street Data',
    description:
      'Making fundamental research easier at-a-glance.',
    link: { href: 'https://mainstreetdata.com', label: 'mainstreetdata.com' },
    logo: logoMainStreetData,
  },
  {
    name: 'Strong Force Solutions',
    description:
      'A small tech-startup trying to make a big impact. Currently in stealth-mode. Occasionally doing contract work and overseeing junior devs.',
    link: { href: 'https://strongforcesolutions.com', label: 'strongforcesolutions.com' },
    logo: logoStrongForceSolutions,
  },
  {
    name: 'Artisus',
    description:
      'Word art for the modern age. Currently in closed-beta.',
    link: { href: 'http://artisus.io', label: 'artisus.io' },
    logo: logoArtisus,
  },
]

export const metadata: Metadata = {
  title: 'Projects',
  description: 'Things I’ve made trying to put my dent in the universe.',
}

export default function Projects() {
  return <CardGridPage
    title="Reasons I'm sleep deprived"
    description=" Over the years I've worked on a small number of projects that I'm proud of. 
    A few of them have consumed me, and a few of them have been consumed by me."
    cardContent={projects}
  />
}
#

But then I have the problem of every single page that needs this layout needing all of the same boilerplate. In other words, I have to export metadata and a default JSX element.

The "CardGridPage" component is the entire page, all in one, so there's no reason for this boilerplate. No customization is needed; all of these pages will look the exact same.

#
import { StaticImageData } from "next/image";
import { type Metadata } from 'next'
import { CardGridPage } from '../_components/CardGridPage'

export interface CardContent {
  name: string
  description: string
  link: { href: string; label: string }
  logo?: StaticImageData
}

interface Exports {
  metadata: Metadata
  Page: () => JSX.Element
}

/**
 * This factory will provide a metadata object and a page component. 
 * The metadata and page component will have the same title and description.
 * This is useful for applying the same "layout" to multiple pages, but with different content.
 * This is necessary because Next.js doesn't support passing props between parent layouts and nested children.
 * @param title The title of the page
 * @param description The description of the page
 * @param cards The cards to be displayed on the page (see CardContent interface)
 * @returns A tuple containing the metadata object and the page component
 */
export function cardGridPageFactory(title: string, description: string, cards: CardContent[]): Exports {
  const metadata: Metadata = {
    title,
    description
  }

  function Page() {
    return <CardGridPage
      title={title}
      description={description}
      cardContent={cards}
    />
  }

  return { metadata, Page }
}
#

Now, I can just replace my /projects/page.jsx content with the following:

import { cardGridPageFactory } from '../_utils/cardGridPageFactory'
import logoArtisus from '@/images/logos/logoArtisus.png'
import logoMainStreetData from '@/images/logos/logoMainStreetData.jpeg'
import logoStrongForceSolutions from '@/images/logos/logoStrongForceSolutions.png'

const projects = [
  {
    name: 'Main Street Data',
    description:
      'Making fundamental research easier at-a-glance.',
    link: { href: 'https://mainstreetdata.com', label: 'mainstreetdata.com' },
    logo: logoMainStreetData,
  },
  {
    name: 'Strong Force Solutions',
    description:
      'A small tech-startup trying to make a big impact. Currently in stealth-mode. Occasionally doing contract work and overseeing junior devs.',
    link: { href: 'https://strongforcesolutions.com', label: 'strongforcesolutions.com' },
    logo: logoStrongForceSolutions,
  },
  {
    name: 'Artisus',
    description:
      'Word art for the modern age. Currently in closed-beta.',
    link: { href: 'http://artisus.io', label: 'artisus.io' },
    logo: logoArtisus,
  },
]

const { metadata, Page } = cardGridPageFactory(
  "Reasons I'm sleep deprived",
  "Over the years I've worked on a small number of projects that I'm proud of. A few of them have consumed me, and a few of them have been consumed by me.",
  projects
)

module.exports.metadata = metadata
module.exports.default = Page
#

I might have been looking in the wrong places, but I haven't seen this kind of pattern used a lot recently. I was wondering if anybody could point out some obvious drawbacks/problems I'm not seeing with the pattern.

I know that it enforces a specific layout and makes it harder to customize each individual page, but it's a strict requirement that they all have the same layout and look the exact same.

Thought I'd put this all here to see what the community thought and get some feedback.

#

(Note, there is a typing issue in the code that Next.js does not like. I haven't modified the code to fix this issue just yet; it only presents itself after running next build)

#

I suppose my question is about whether Next.js specific exports, like metadata, can be used outside of a page.js file and inside of a component file (for example, the CardGridPage component I made). I'm 90% sure this isn't possible since each page.js file gets special treatment from the Next.js compilation steps, but I want to double check.

dapper wyvern
#

You can use metadata in layout.js as well, but why have a separate component at all? Unless you’re using react hooks?

#

It works as a fallback in layout

noble mountain
#

I can't pass the custom "title" and "description" texts back to the layout. These are defined on a per-page basis since the title and descriptions are used in both the metadata and the page itself.

Also, like I said, the layout for both pages are exactly the same. The only difference is the header text, the description text, and the text inside the cards. Everything else is identical. I use a separate component because I plan to have more than three pages with this exact same layout, but need to find way to pass information inside of the page files into the parent layout.js files.

Now, I didn't think about extracting the metadata into the layout.js and then generating the export metadata dynamically based on the layout, but see now that it is possible. The only issue I have with this approach is that I have to define all of this metadata configuration inside of a single layout file or colocate the information somewhere else in the same route level. This means more developer memory overhead if I ever want to change the "title" of the page since, as I mentioned, the literal <h1> title content and the metadata's title are the same. I prefer them both just being inside of the same page.js file.

dapper wyvern
#

Passing anything back to layout is anti pattern. Page metadata overwrites layout metadata

noble mountain
#

That's kind of the point of my entire discussion?

#

It's getting hard to talk about lol because I keep using "layout" to refer to two different things

#

My bad

#

I'm not trying to pass anything to the Next.js layout.js. I'm trying to re-use the same rendered HTML element structure, with the only difference being in textual content and nothing else.

I am trying to find a pattern that lets me do a similar thing to what the Next.js layout.js set out to achieve, but without using the Next.js layout.js since it does not support passing data and accepting data from children components.

So I'm asking whether the factory pattern I've proposed has any drawbacks as a potential solution for this, and if so, what is a better pattern. Each Next.js page.js just calls the cardGridPageFactory function and automatically gets the correct metadata and appropriate renderedHTML structure without needing a layout.js. This let's me reduce boilerplate and obtain the desired effect: re-using the same rendered HTML structure.

Sorry if I'm missing something obvious.

#

I do know that this is something that parallel routing can help with. For example, adding a @tools slot and defining the tools CardContent object inside of there allows me to get away from this factory pattern.

I believe that slotting in the CardContent from a layout.js file is more idiomatic Next.js.... So that's why I'm asking.

dapper wyvern
#

You can pass functions down to update data in HOC's though

#

I guess I'm not sure what metadata you would need to handle from the cardGridPageFactory

#

Your "layout" could be just a client or server component - i'm just confused by the need for metadata

noble mountain
#

In this situation, data is not being passed up the tree since I'm generating a new component for each invocation of the cardGridPageFactory. It's more like I'm passing the the text content I want rendered down the tree into the newly created page.js. The only reason I'm talking about passing anything up the tree is because I want to define the content for the page.js inside of the page.js, but the layout itself is general and can be abstracted outside of the context of a page.js route.

The metadata isn't necessary, but I want it. I also want the metadata to have the same "title" and "description" as what I pass to the <CardGridPage /> component. The catch, however, is that each page.js has it's own unique "title" and "description". Every single one of these page.js files will be like this, so they all need the same boilerplate. The HoC pattern would let me reduce this boilerplate, and it's why I'm exploring the concept.

dapper wyvern
#
/**
 * This factory will provide a metadata object and a page component. 
 * The metadata and page component will have the same title and description.
 * This is useful for applying the same "layout" to multiple pages, but with different content.
 * This is necessary because Next.js doesn't support passing props between parent layouts and nested children.
 * @param title The title of the page
 * @param description The description of the page
 * @param cards The cards to be displayed on the page (see CardContent interface)
 * @returns A tuple containing the metadata object and the page component
 */

I just got back so I can look at this a bit more

I'm sorry but this makes absolutely no sense at all and I have no idea what you're trying to achieve or why you would ever want to do this.

noble mountain
#

The pattern I suggest does the same exact thing. The difference is that I can define the content inside of the page.js file that it actually pertains to, instead of having to define a large configuration object inside of the dynamic [slug] route segment, and match the slug against this configuration object.

I could define the configuration objects elsewhere and import then into the dynamic route for matching against the slug to generate the metadata, but then I end up in the same position as just having individual page.js files.

#

For example, I implement the pattern you suggest the following way:

export function generateMetadata(
    { params },
    parent: ResolvingMetadata
  ): Metadata {
    const content = contentConfig.get(params.cardPage)

    return {
      title: content?.title || "Default Title",
      description: content?.description || "Default Description",
    }
  }
  
export default function Page({ params }) {
    const slug = params.cardPage;

    const content = contentConfig.get(slug);

    return <CardGridPage
        title={content?.title || ""}
        description={content?.description || ""}
        cardContent={content?.cards || []}
    />
}
#

Then I define the contentConfig Map:

interface ConfigurationValue {
    title: string,
    description: string,
    cards: CardContent[]
}

const contentConfig: Map<string, ConfigurationValue> = new Map([
    [
        "test", 
        { 
            title: "Tools",
            description: "Tools I use",
            cards: [
                {
                    name: "Tool 1",
                    description: "Description 1",
                    link: { href: "https://google.com", label: "Google" }
                },
                {
                    name: "Tool 2",
                    description: "Description 2",
                    link: { href: "https://google.com", label: "Google" }
                }
            ]
        }
    ],
    [
        "projects",
        {
            title: "Projects",
            description: "Projects I've worked on",
            cards: [
                {
                    name: "Project 1",
                    description: "Description 1",
                    link: { href: "https://google.com", label: "Google" }
                },
                {
                    name: "Project 2",
                    description: "Description 2",
                    link: { href: "https://google.com", label: "Google" }
                },
            ]
        }
    ]
])
#

My requirements are that I have more than three of these pages, and each of these pages could have quite a few cards displayed as a ConfigurationValue['cards']. I don't like having this massive configuration object in a single file, regardless of whether it is idiomatic Next.js or not.

And splitting these configuration objects into separate .json files or .js doesn't feel as clean as just having multiple page.js files that define the relevant configuration, which the HoC factory lets me do.

#

I guess this does tie back into what I mentioned earlier with using parallel routing to do some of this work.

dapper wyvern
#

I see absolutely no benefit to doing it this way, you're not using the framework as intended

#

you're describing how dynamic routing works without actually using dynamic routing

#

You only need a single page file

#

I guess I'm also not sure what you mean by "more than three of these pages", it sounds like you're describing a single route segment

dapper wyvern
noble mountain
#

Thank you for removing the emoji.

What do you suggest the appropriate method for having the configuration for 4 pages inside of this dynamic route, with each page having at least 6 elements inside of the cards array for that page's configuration?

It gets large very fast. Having all of this inside of a single file is ridiculous, especially if you have reason to believe that it is going to grow since you are actively adding content to the site. If you just extract the config into a separate .js file or .json file, that works, but it's still just as big. Trying to split that configuration by page results in achieving the same exact thing as what the HoC does since allows splitting the configuration into the appropriate page.js rather than just splitting the configuration object into multiple .js or .json files. I would prefer just having multiple page.js files instead of a single page.js and multiple different configuration files for the relevant [slug]. I think we are arguing a matter of preference at this point.

I don't necessarily think these all need to be part of the same route segment, more like the same route group. Unless you're talking about a dynamic [slug], then yeah they would be the same.

noble mountain
dapper wyvern
#

the page.js doesn't change, just the data in your contentConfig

#

the better solution would be to just use a database if you're concerned about managing a json file

noble mountain
dapper wyvern
#

There's also the option of MDX

#

On one of my sites I use MDX to manage content

// /app/blog/[post]/page.js

import { serialize } from "next-mdx-remote/serialize";
import { promises as fs } from "fs";
import { MdxContent } from "./mdx-content";
import { View, Common } from "../client/View";
import Box from "../Box";
import { Sound } from "../Sound";
import space from "../sounds/SF-bkg-space-loop-1.wav";
import { view } from "../index.module.css";

import path from "path";
import Link from "next/link";
import Image from "next/image";
import profilepicture from "./Subject.png";

async function getAllPostFilenames() {
  // Get all MDX files from the posts directory
  const postsDir = path.join(process.cwd(), "./posts");
  const filenames = await fs.readdir(postsDir);
  return filenames.map((filename) => filename.replace(".mdx", ""));
}

async function getPost(filepath) {
  // Read the file from the filesystem
  const raw = await fs.readFile(`./posts/${filepath}.mdx`, "utf-8");

  // Serialize the MDX content and parse the frontmatter
  const serialized = await serialize(raw, {
    parseFrontmatter: true,
  });

  // Typecast the frontmatter to the correct type
  let frontmatter = serialized.frontmatter;
  frontmatter.slug = filepath;

  // Return the serialized content and frontmatter
  return {
    frontmatter,
    serialized,
  };
}

function sortPostsByDate(posts) {
  return posts.sort((a, b) => {
    const dateA = new Date(a.frontmatter.date);
    const dateB = new Date(b.frontmatter.date);
    return dateB - dateA; // Sort from newest to oldest
  });
}

export default async function PostsPage() {
  const postFilenames = await getAllPostFilenames();

  const posts = await Promise.all(
    postFilenames.map(async (filename) => {
      return await getPost(filename);
    })
  );

  const sortedPosts = sortPostsByDate(posts);

  const sidebarStyles = {
    position: "fixed",
    right: "50px", // Adjust this value for the desired padding
    top: "50%",
    transform: "translateY(-50%)",
    border: "1px solid white",
    borderRadius: "5px",
    padding: "10px",
    textAlign: "center",
    color: "white",
    width: "200px",
  };

  return (
    <>
      <Link href="/">
        <div
          style={{ position: "absolute", top: 10, left: 10, cursor: "pointer" }}
        >
          ← HOME
        </div>
      </Link>

      <View className={view}>
        <Box position={[0, 0, 0]} />
        <Sound url={space} />
        <Common />
      </View>

      <div style={{ maxWidth: 600, margin: "auto", color: "white" }}>
        {sortedPosts.map((post, index) => (
          <div key={index}>
            <h1>{post.frontmatter.title}</h1>
            <p>Posted: {post.frontmatter.date}</p>
            <hr />
            <MdxContent source={post.serialized} />
          </div>
        ))}
      </div>
      <div style={sidebarStyles}>
        <Image src={profilepicture} width={100} height={100} />
        <h3>Marchy&apos;s Blog</h3>
        <p>Welcome to my incoherent ramblings. Enjoy</p>
      </div>
    </>
  );
}
noble mountain
#

I played around with both approaches for a while based on the large configuration file.

I have always seen your point throughout the discussion, but I was under the impression that it was still simpler to use the HoC factory. After playing with them both though, in terms of the developer experience, I agree with you completely about the dynamic routing as the solution for this even with the gigantic configuration file/configuration files.

Once I thought about the indirection necessary to implement the HoC cleanly, it really stood out to me that dynamic routing is just a simpler thing to do overall. I was ignoring the extra file declarations inside of the _component and _util folder I had to implement the HoC.

I appreciate the discussion. I just really wish you didn't laugh react my code.

dapper wyvern