NextJS 14 Project: A Portfolio page (with Markdown + Contentlayer + Notion)

Today, we're using NextJS 14 and the app/router to create a project portfolio page for showcasing your work.

The goal is to build a page that displays a grid of images based on the provided content. Each image will have the project title and a hover effect. Clicking on an image will open a new page with relevant data such as the date, project description, and possibly a link for more information.

Now, how do we achieve this?

  • First, we'll install the latest version of NextJS with TypeScript.
  • Next, we'll set up content management within the project using a tool called ContentLayer.
  • In my case, I'll import the content from a Notion database, but you can also manage it manually as Markdown in the codebase if you prefer.
  • Finally, we'll use the content to display the portfolio pages on the website.

To create the index page with a grid layout for showcasing all the portfolio items, we'll use Tailwind CSS. Additionally, we'll create a layout for each individual project page.

This means we'll be coding two pages in NextJS: one for displaying the portfolio list and another for showcasing a specific project.

But first... let's set up the project.

Project Installation

The first step is to start a new NextJS project. That’s easily accomplished by typing :

npx create-next-app@latest

Here the installer asks us a series of questions.

The first question is what we want to call our project, I’ve called mine “portfolio-website”. Not very original, I know.

The next questions are all about what else we want to install, and we’re going to accept the default answer to all of them, since we want TypeScript, and ESLint and Tailwind, the src folder, and the App Router. And we don’t need to change the “default import alias”.

Once that is done, the next step is to change directories to go into the project directory. In my case :

cd portfolio-website

Now let’s install ContentLayer :

npm install contentlayer next-contentlayer --force

ContentLayer only has dependencies til Next 13, but as there are no breaking changes in Next 14 we can import it.

Just to make sure updates work fine, it’s work going in to the package.jsonand adding

"overrides": {
    "next-contentlayer": { "next": "$next"}

Finally we’ll also import the NPM module I created to parse notion content :

npm install @kodaps/notion-parse dotenv -D

Let’s just start up the project to make sure everything is working fine.

npm run dev

Looks good to me 🙂 Now let’s set up the content

Setting Up the Content

Let’s start by setting up the content.

There is basically two parts to this. Setting up the ContentLayer Schema and setting up the database in Notion. I’ve you’re interested I’ve explained previously why I use Markdown to store my content and ContentLayer to validate the frontmatter schema, so I won’t be going over it in detail again here.

There are several things to know if you haven’t seen the videos.

The first thing to know is that we’ll be storing our Markdown content inside the /src/content/portfolio/ directory. (Just to be safe, let’s create this directory. )

The second thing to know, is that Markdown can store structured data at the top of a Markdown file, this allows us to store important data within the content itself. Here is an example in a existing file.

The third thing to know is that the ContentLayer parses these files, checks the schema and then give us the means to access them in our code

In terms of Schema, what do we need for the Porfolio items?

In our case, here, the obligatory fields will be

  • the title, which is. a string
  • the slug, which is also a string
  • the date
  • and the main image

The non required fields are the notionId field, if you want to store your information in Notion, and a tags field which will be a list of strings.

The image is slightly more complex, because we want to store the path to the image, but also the image’s with and height. So for this we’re going to use something called a NestedType, where the field contains an objects.

I’ll show you how in a second, first lets set up the Schema. We need to set up a contentlayer.config.ts file at the root of our project.

This file starts our with :

import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer/source-files'

The first thing is to define our image type. An image is basically a path to the image, which is a string, and a width and a height, which are both numbers. To define this we use ContentLayer’s syntax, where we call defineNestedType with a function that returns an object, and that object has a name field, here, Image and a fields field, which has the three fields I mentioned. They’re all required. Width and height are numbers, and srcis a string.

const Image = defineNestedType(() => ({
  name: 'Image',
  fields: {
    width: { type: 'number', required: true },
    height: { type: 'number', required: true },
    src: { type: 'string', required: true },

Now let’s define the Portfolio type, using the image nested type we’ve just defined. The other interesting field is the tags, which as I mentioned is a list of strings. The other fields are self explanatory, they simply state what their type is, i.e. here

export const Portfolio = defineDocumentType(() => ({
  name: 'Portfolio',
  filePathPattern: `portfolio/**/*.md`,
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
		slug: { type: 'string', required: true },
    notionId: { type: 'string', required: false },
	  tags: {type: 'list', of: {type: 'string'}},
	  image: {type: 'nested', of: Image, required: true },

Now let’s tell ContentLayer where to find our content, and what the types are :

export default makeSource({
  contentDirPath: 'src/content',
  documentTypes: [ Portfolio]

Now let’s head over to the next.config.js file.

First let’s import the function that wraps the configuration to add ContentLayer processing :

const { withContentlayer } = require('next-contentlayer')

Finally, if we look at the export, we call this fonction on the configuration object, so the module exports withContentLayer of the nextConfig object :

module.exports = withContentlayer(nextConfig)

The final step is to go in to the tsconfig.json file. Here we need to the baseUrl and add an alias for the path to the generated files.

  "compilerOptions": {
		"baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "contentlayer/generated": ["./.contentlayer/generated"]

Creating the content

As I explained previsously, I’ve created an NPM package that will fetch the content directly from Notion and save it as a Markdown file with front matter. That NPM module is called @kodaps/notion-parse

I’ve also share the Notion template, if you want to steal it.

Once it is set up, with the notion secret and database id, my script looks something like this :

const NotionParse = require('@kodaps/notion-parse');
const dotenv = require('dotenv');


const go = async () => {

  if (process.env.NOTION_TOKEN) {
    await NotionParse.parseNotion(process.env.NOTION_TOKEN, './src/content', [
      { databaseId: process.env.NOTION_PORTFOLIO_DATABASE_ID || '', contentType: 'Portfolio' },      
    ], true)
  } else {
    console.error('Missing NOTION_TOKEN environment variable');


go().then(() => {

As you can see, I pass in the notion token that is read from the environment file, and the configuration with the database id.

If I run this it download all the content as markdown

Setting up the pages

The main (index) page

The next step is to set up the pages

We’ll start by creating a portfoliofolder in the app/ directory. Now I’m supposing the portfolio is a sub section of the website, if you want this to be the main page of the website, set this page up directly in the root of the app/ folder.

In our folder we’ll start by creating a page.tsx file.

Let’s start by setting up a simple page :

export default async function PortfolioIndex() {
  return (
    <section className="mx-auto max-w-3xl">
        <h1 className="text-center text-4xl font-bold">
        Portfolio Index
      <div className="p-4 md:p-0 grid grid-cols-3">

Now inside the component,we’ll fetch all the portfolio items. To do so is trivial, we just need to import allPortfolios from contentlayer/generated, and read it in our code.

**import { allPortfolios } from 'contentlayer/generated';**

export default async function PortfolioIndex() {
	**const items = allPortfolios;**
  return (
    /* .this part is unchanged for now.. */

Now we just need to set up the content for the grid, for example by running a map on the items object that creates a card for each item:

      <div className="p-4 md:p-0 grid-cols-3">
				{, index) => <PortfolioCard item={item} />}

Now we need to set up the PortfolioCard component. For that let’s head to /src/components/ and create a component by the same name. The item that is passed in as a prop is a Portfolio item, thankfully that is already defined by ContentLayer so we can just import it and use it in the interface for the props.

Our portfolio card is going to be very basic. We have a containing div which is full width. Inside that we have an image which uses the object-cover and aspect-square Tailwind classes, and reads the source path ( src ) , the width, the height and the alt fields from the portfolio item data.

// src/components/PortfolioCard.tsx 

import { Portfolio } from 'contentlayer/generated';
import Image from 'next/image';

interface PortfolioCardProps {
 item: Portfolio 

 export const PortfolioCard:React.FC<PortfolioCardProps> = ({item}) => {

	return <div className="w-full ">
		<Image className="object-cover aspect-square" 
           alt={item.title} />

As you can see the grid is shaping up nicely, let’s now go and set up the per item view.

Item Details view

Next to the page.tsx file where we set up the grid, we’ll create a new directory called [slug]

And in this directory we’ll create another page.tsx file.

This page will be used to show a specific item, and we’ll use the slug parameter to identify it.

This means the props for the functionnal component for the page has the following interface :

interface Params {
  params: {
    slug: string,

Now let’s start writing the page component using this interface :

const Page:React.FC<Params> = ({params: {slug}}) => {
  return <h1>{slug}</h1>;

export default Page;

If we start the server up and head to http://localhost:3000/portfolio/blablabla we can see that the slug is indeed translated as the title of the page.

The next step is to use the slug to fetch the item corresponding to the slug, and use the item we retrieve to show a title

import { allPortfolios } from "contentlayer/generated";

/* .. */

const Page:React.FC<Params> = ({params: {slug}}) => {

  **const item = allPortfolios.find((item) => item.slug === slug);**

  return <h1>**{item?.title}**</h1>;

export default Page;

However if we reload the page, nothing shows. Can you guess why?

It’s because the slug we entered doesn’t match any of our content.

Let’s go and take a look at the content. I have a slug called “a-new-beginning”, let’s use that and head to http://localhost:3000/portfolio/a-new-beginning

As you can see the title has updated.

Managing slug not found

Now to be able to mange the case where the slug is wrong, we have two possibilities. We can redirect to the index page of the portfolio, or we can show a 404 page. We’ll go for the second option :

import { notFound } from 'next/navigation'

/* .. */

const Page:React.FC<Params> = ({params: {slug}}) => {

  const item = allPortfolios.find((item) => item.slug === slug);

  if (!item) {

  return <h1>{item?.title}</h1>;

export default Page;

Now if we set the slug to “blablabla” we have the 404 error.

Finishing the details page

Now let’s improve the page a little. On the h1 let’s add some classes to make the text more “title like” : font-bold text-3xl py-3 . Let’s also wrap all our page content in a div with a limited width:

const Page:React.FC<Params> = ({params: {slug}}) => {

	/* ... */

  return <div _className_="mx-auto max-w-3xl p-8"> 
          <h1 className="font-bold text-3xl py-3">{item?.title}</h1>

Now we just need to add in the content :

	<div className="grid grid-cols-1 grid-rows-2">
      <Image src={item?.image.src} width={item?.image.width} height={item?.image.height} alt={item?.title} />
      <div dangerouslySetInnerHTML={{__html: item.body.html}}/>

Obviously the layout could be improved, but doing sexy CSS layouts isn’t my biggest strength and any layout I’d do would probably not fit with the style you want for your own website, but you get the gist of it.

And as you can see when you load the page, the markdown content is loaded in from Notion.

Now there is a final bit we need to do, and that is the navigation.

Let’s just add a link back to the portfolio index page in this page

Let’s start by importing the Link component in the details page :

import Link from 'next/link';

Now let’s add a link back to the portfolio page :

<Link href="/portfolio" className="text-blue-500 hover:text-blue-700">
 ← Back to Portfolio

If we test that out… we can see that it works.

Navigating the portfolio page

Now there is one last thing we need to do, and that is navigating from the portfolio page to the details page. Let’s go to the PortfolioCard component

Once again we need to import the Link component

import Link from 'next/link';

Now we just need to wrap the main div in a link that uses the slug to create the path:

<Link href={"/portfolio/" +  item.slug }>
{/* .. */}

Our PortfolioCard component now looks something like this :

import { Portfolio } from 'contentlayer/generated';
import Image from 'next/image';
import Link from 'next/link';

interface PortfolioCardProps {
 item: Portfolio

export const PortfolioCard:React.FC<PortfolioCardProps> = ({item}) => {

	return <Link href={"/portfolio/" +  item.slug }>
    <div className="w-full">
         className={"aspect-square object-cover"} 
         alt={item.title} />

Now of course we could pimp it up. I’ve shared a link to the code and I may well have added a few classes to spice up the presentation, if I have the time. But there you have it, a portfolio website that relies on Markdown, ContentLayer and Notion. I hope this was helpful, and let me know if there is anything else you’d like to see.

Made by kodaps · All rights reserved.
© 2023