Building a portfolio website using Nextjs - Part 1 - ( Project Details)

A step-by-step guide on building a complete portfolio + blog + gallery website using Next JS (app router)

In this series, we gonna deep dive on building a complete end-to-end portfolio website using Nextjs 14 (app router)

The tech stack and libraries/packages that we will use to build this website will be :

  • Nextjs 14, React

  • Tailwind CSS

  • Daisy UI

  • ESLint

  • ContentLayer

  • Lucide Icons

The Portfolio Website Design

This portfolio will have 6 pages

  • Home

  • About

  • Blog

  • Gallery

  • Portfolio

  • Contact

We will be using this Figma mocks to build our portfolio website.

Setup Nextjs 14 (app router)

  1. Setup Next.js 14 (app router)

    To start, we will create our nextjs app using the following command:

$ npx create-next-app@latest

  1. Setup Daisy UI

    Daisy UI is a beautiful open-source UI Library built on top of Tailwind CSS. For this we are going to use this library.

$ npx i -D daisyui@latest

Add daisyUI to tailwind.config.js , update the plugins array :

import daisyui from "daisyui"

plugins: [
  daisyui
]

Create a button in the homepage :

<button className="btn btn-primary">Button</button>

If the button shows up as blue button, great its working.

Setup ContentLayer

So we will use content layer for converting our static content in to json format. You can find more information about it here.

We gonna use this package as we are using Next 14, the old version is supported only till Next 13.

Run the following command to install content layer :

npm i contentlayer2 next-contentlayer2 data-fns

Once installed, we need to configure contentlayer with Next JS

To hook contentlayer in to the next dev and next build processes, you'll want to wrap the Next.js configuration using the withContentlayer method.

update next.config.mjs file with this :

// @ts-check
/** @type {import('next').NextConfig} */
import { withContentlayer } from "next-contentlayer2";

const nextConfig = { reactStrictMode: true, swcMinify: true };

export default withContentlayer(nextConfig);

Typescript configuration

Then add the following lines to tsconfig.json

{
 "compilerOptions": {
    "baseUrl": ".", // add this
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
        {
            "name": "next"
        }
    ],
    "paths": {
        "@/*": ["./src/*"],
        "contentlayer/*": ["./.contentlayer/generated"] // add this
    }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".contentlayer/generated"], // add this
"exclude": ["node_modules"]
}

This configures the Next.js build process and your editor to know where to look for generated files, and to make it easier to import them in to your code.

Add this in .gitignore

# contentlayer
.contentlayer

Add the following plugins (later not now):

remark-toc - used for generating table of contents

Update Contentlayer.config.ts

//contentlayer.config.ts
import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer2/source-files'

const Author = defineNestedType(() => ({
    name: 'Author',
    fields: {
        name: { type: 'string', required: true },
        picture: { type: 'string', required: true },
    },
}));

export const Post = defineDocumentType(() => ({
    name: 'Post',
    filePathPattern: `**/*.md`,
    contentType: 'markdown',
    fields: {
        title: {
            type: 'string',
            description: 'The title of the post',
        },
        description: {
            type: 'string',
        },
        publishedAt: {
            type: 'date',
            description: 'The date the post was published',
        },
        status: {
            type: 'enum',
            options: ['published', 'draft'],
            default: 'draft',
            required: false,
        },
        slug: {
            type: 'string',
            description: 'The slug of the post',
            required: false,
        },
        author: {
            type: 'nested',
            of: Author,
            required: false,
        },
        featured: {
            type: 'enum',
            options: ["yes", "no"],
            default: 'no',
            required: false,
        },
        tags: {
            type: 'string',
            required: false,
        },
        coverImage: {
            type: 'string',
            description: 'The cover image of the post',
            required: false,
        },
        date: {
            type: 'date',
            description: 'The date the post was published',
        },
    },
    computedFields: {
        url: { type: 'string', resolve: (post) => `/blog/${post._raw.flattenedPath}` }
    },
}))

export default makeSource({
    contentDirPath: 'src/content/posts',
    documentTypes: [Post],
})

This configuration specifies a single document type called Post. These documents are expected to be Markdown files that live within a src/content directory in your project.

Any data objects generated from these files will contain the fields specified above, along with a body field that contains the raw and HTML content of the file. The url field is a specified computed field that gets automatically added to all post documents, based on meta properties from the source file.

Add the below in package.json (scripts)

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "build:content": "contentlayer2 build" // add this
  },

Creating posts

Now create few posts:

create a hello-world.md

---
title: Hello World !
date: 2024-07-18
description: 'Welcome to our blog'
publishedAt: 2024-07-18
status: published
slug: '/hello-world'
tags: 'general'
---

### Hello World

Welcome to our blog ! Thanks for visiting. Stay tuned

And another post:

---
title: Hello World again !
date: 2024-07-18
description: 'You are still here, thanks !'
publishedAt: 2024-07-18
status: published
slug: '/hello-world-again'
tags: 'general'
---

Thanks man, 

I really appreciate you watching the video :) I hope you follow it completely to build the portfolio website.

Now run the build content command:

npm run build:content

This will generate .contentlayer folder with all the generated json content for use.

Creating the Blog

To test it, we need to build a blog page to display all posts, and a post page to display single blog post.

Let's do that:

Let's create our Blog posts page :

Create a new page under app/blog/page.tsx

import React from "react";
import Link from "next/link";
import { compareDesc, format, parseISO } from "date-fns";
import { allPosts, Post } from "contentlayer/generated";

export const metadata = {
  title: "Blog",
  description: "Welcome to my blog",
  languages: {
    "en-US": "/en-US",
  },
  keywords: ["blog", "posts", "articles"],
  category: "technology",
};

function PostCard(post: Post) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link
          href={post.url}
          className="text-blue-700 hover:text-blue-900 dark:text-blue-400"
        >
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {post && post.date ? format(parseISO(post.date), "LLLL d, yyyy") : ""}
      </time>
      <div
        className="text-sm [&>*]:mb-3 [&>*:last-child]:mb-0"
        dangerouslySetInnerHTML={{ __html: post.body.html }}
      />
    </div>
  );
}

const Blog = async () => {
  const posts = allPosts.sort((a, b) =>
    a.date && b.date ? compareDesc(new Date(a.date), new Date(b.date)) : 0
  );
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">
        Welcome to my Blog
      </h1>
      <h2 className="flex pb-6 text-2xl font-extrabold tracking-tight text-gray-400 dark:text-gray-300 sm:text-2xl md:text-2xl">
        Recent Posts
      </h2>
      <hr className="border-gray-200 dark:border-gray-700" />

      {!posts.length && "No posts found."}

      {posts.map((post, idx) => (
        <PostCard key={idx} {...post} />
      ))}
    </div>
  );
};

export default Blog;

Create a blog post page : blog/[slug]/page.tsx

import { format, parseISO } from "date-fns";
import { allPosts } from "contentlayer/generated";
import Link from "next/link";
import Image from "next/image";

import { redirect } from "next/navigation";

export const generateStaticParams = async () =>
  allPosts.map((post) => ({ slug: post._raw.flattenedPath }));

// generate meta data
export const generateMetadata = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);

  if (!post) throw new Error(`Post not found for slug: ${params.slug}`);

  return {
    title: post.title,
    description: post.description,
    keywords: post.tags,
  };
};
const PostLayout = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
  if (!post) {
    console.error(`Post not found for slug: ${params.slug}`);
    redirect("/404");
  }

  return (
    <div className="mx-auto max-w-xl py-20">
      <article className="mx-auto max-w-3xl py-8 prose md:prose-lg lg:prose-xl dark:prose-invert">
        <div className="mb-8 text-center">
          <time dateTime={post.date} className="mb-1 text-xs text-gray-600">
            {post && post.date
              ? format(parseISO(post.date), "LLLL d, yyyy")
              : ""}
          </time>

          <h1 className="text-3xl font-bold">{post.title}</h1>
          <p className="prose-free">{post.description}</p>

          {post.coverImage &&
            (post.url ? (
              <Link href={post.url} aria-label={`Link to ${post.title}`}>
                <Image
                  src={post.coverImage}
                  alt={post.title || ""}
                  width={600}
                  height={400}
                  layout="responsive"
                  className="object-cover object-center md:h-36 lg:h-48"
                  priority
                />
              </Link>
            ) : (
              <Image
                alt={post.title || ""}
                src={post.coverImage}
                width={600}
                height={400}
                layout="responsive"
                className="object-cover object-center md:h-36 lg:h-48"
                priority
              />
            ))}
        </div>
        <div
          className="[&>*]:mb-3 [&>*:last-child]:mb-0"
          dangerouslySetInnerHTML={{ __html: post.body.html }}
        />
      </article>
    </div>
  );
};

export default PostLayout;

If you now visit, /blog page now you should be able to see two blog posts:

Setup Lucide Icons

Let's add some beautiful icons to our project :

We will be using lucide icons (https://lucide.dev) an open source svg icons library, it uses svg compression and it supports tree-shaking (meaning only ship the icons you use)l. So only the imported icons contribute to the weight of the files and reduce file size.

npm install lucide-react

For nextjs, we gonna use the dynamic imports

Update our next.config.mjs to this :

// @ts-check

/** @type {import('next').NextConfig} */
import { withContentlayer } from "next-contentlayer2";

const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  transpilePackages: ["lucide-react"], // add this
};

export default withContentlayer(nextConfig);

Creating a reusable Icon component to use our Lucide Icons dynamically :

src/components/Icon.tsx

"use client";
import dynamic from "next/dynamic";
import { LucideProps } from "lucide-react";
import dynamicIconImports from "lucide-react/dynamicIconImports";

interface IconProps extends LucideProps {
  name: keyof typeof dynamicIconImports;
}

const Icon = ({ name, ...props }: IconProps) => {
  const LucideIcon = dynamic(dynamicIconImports[name]);

  return <LucideIcon {...props} className="dark:text-white text-black" />;
};

export default Icon;

Then we can use this anywhere with the icons like:

import Icon from './Icon';

//Usage 
const App = () => {
   // before
  // return <Camera color="red" size={48} />; 

    //after (renders the icon dynamically only when required)
    return <Icon name='camera' color="red" size={48} />
}; 

export default App;

That's it for the part1, we have setup the base for our frontend.

In the next part (PART-2), we will be setting up the website layout by building all the components required.

Thanks.

0
Subscribe to my newsletter

Read articles from xplor4r (Maintainer) directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

xplor4r (Maintainer)
xplor4r (Maintainer)

Open Source Developer's Community Maintainer