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)
Setup Next.js 14 (app router)
To start, we will create our nextjs app using the following command:
$ npx create-next-app@latest
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.
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