How I built my portfolio using NextJS, Typescript, and Chakra UI
In this post I’ll go with you in a detailed way on how i created my blog/portfolio from scratch using NextJS and Chakra UI, from creating the pages and posts markdown files to displaying them as html files, And how I handled newsletter service, and deploying the project on Vercel, Handling custom domains. So this post will good for you if you want to know more about anything that I’ve implemented here on my blog.
Here's the source code for this site.
NextJS + Typescript + Chakra UI
NextJS
is one of the strongest framework to build a production project. I've used some features like SSG or pre-render pages at build time, API routes, file-system routing, fast refresh, configuration and deployments are super easy.
To get started just write this npx create-next-app@latest --ts
.
The structure of the portfolio goes like that:
.
├── components
│ ├── Admin
│ ├── CareerStack
│ ├── ColorModeIcon
│ ├── Footer
│ ├── Layout
│ ├── MarkdownRender
│ ├── Navbar
│ └── Newsletter
├── configs
├── interfaces
├── libs
├── pages
│ ├── api
│ │ └── auth
│ └── posts
├── public
│ └── static
│ ├── images
│ └── sounds
├── styles
│ └── theme
└── utils
NextJS uses the pages
folder to handle the app routes, as you may know if you created
a file called pages/hello.tsx
you can open it by visiting your-app.com/hello
and that's super cool. So I created a folder called pages to handle the routes for
the app. Also I created another folder called components
to handle the react components
that will be used across the pages.
Another feature of using NextJS is creating API routes, To do so you can create this folder pages/api
and then create your apis inside it.
If you're building your projects with javascript, then you have to try typescript
, Typescript will definitely help you documenting your code as you go, Also it forces you to use the right type so this will decrease the bugs and speed up your productivity while you're coding.
Here's the styling part, I use chakra-ui
for creating reactjs components and styles without writing css. Also Chakra ui is handling the app color mode, and much more in the styling part. You can customize the theme, declaring the default values for the app you can check this folder for further information about this styles/theme
.
In the mode icon that appears at the top of the site, I've used two things to get this icon based on the current weather of your location. In your first visiting the app, I'm getting your location from https://geolocation-db.com/json/
then store it in the cookies with max-age=3600
expired after 60minutes. When i got your location, I got also the location's weather, then display the icon based on weather. I implemented the logic in this file components/ColorModeIcon/index.tsx
.
Name pronunciation
You probably have heard the pronunciation of my name when you clicked on the sound icon next to my name on the home page.
For this sound i just used Vocalizer
and downloaded my name's sound.
Added it to webpack config to identify the file extension and location next.config.js
config.module.rules.push({
test: /\.mp3$/,
use: {
loader: "file-loader",
options: {
publicPath: "/_next/static/sounds/",
outputPath: "static/sounds/",
name: "[name].[ext]",
esModule: false,
},
},
});
And used it like below inside the onClick
event handler for the icon:
const audio = new Audio("/static/sounds/zeyad_ar.mp3");
audio.play();
Analytics
Analytics is one of the most important parts for any product that users could use. I use Analytics here to just track page views, but in the future i may use it for event tracking like clicking on subscription button (Room for improvement).
To achieve this, I use Google Analytics and countapi-js.
I faced a problem here while using countapi-js
because all of my old analytics have been tracked by Google analytics not countapi-js
, and the target is delievering the actual pageviews for all pages, So i collect the old analytics from GA via this tool https://ga-dev-tools.web.app/query-explorer/
metrics= ga:pageviews
dimensions= ga:pagePath
After that I created a function to create a key in countapi-js
set the key to page path and the default value is the GA pagePath page views count.
countapi.create({
namespace: site.namespace,
key: pagePath,
value: pageViews,
});
For GA, I just added its initialization to _document.tsx
file and using it via this line gtag.pageview(url);
in the _app.tsx
to track all pages listening to router changes.
So in the final producation version, The site is running with two tracking services GA and countapi-js
and they're running independently. But I depend on countapi-js
to view page views and it delivers the actual count as GA.
Posts
Here's the most complicated part of the site, The Blog. But Nextjs made it so simple to organize and retrieve posts, In this section i'll go with you how i organized the blog posts files, not how i handled the markdown files, will explain this in the next section. So, Let's get started.
One of the biggest features in NextJS is Dynamic Routes. I created two files to handle the structure of the blogs pages/posts/index.tsx
and pages/posts/[slug].tsx
to handle the posts list, and the post page respectively.
I created a folder blogs
in the project root for the blogs .md
files, and in libs/posts.ts
file, I created two helper functions getPosts(): Promise<IPost[]>
and getPostBySlug(slug: string): Promise<null | IPost>
- will explain more in the next section -, And used them in pages/posts/index.tsx
and pages/posts/[slug].tsx
in getStaticProps()
Because we need this data to be updated in build time (rebuilding the site) and that make sense.
To know more about nextjs rendering/generating pages/data, look at this site.
And here's how I use Next Link component with Chakra UI Link to do client-side routing, note that we're using passHref
prop to pass href to the Link component. In this way we guarantee that the [slug].ts
is reading the slug correctly using router.query.slug
.
// pages/posts/index.tsx
<NextLink href={`/posts/${fileName}`} passHref>
<Link {...props}>{post.title}</Link>
</NextLink>
To handle the 404
in the not existing post urls, I use getStaticPaths()
with fallback: false
, Here's its code:
export const getStaticPaths: GetStaticPaths = async () => {
const postsSlugs = await getPosts();
const slugs = postsSlugs.map((post) => ({
params: { slug: post?.fileName },
}));
return {
paths: slugs, // predefined posts urls
fallback: false, // any paths not returned by getStaticPaths will result in a 404 page
};
};
That's pretty much for this section, let's go with the next one, rendering markdown files.
Rendering Markdown files
From the previous section, you should know that we're handling the post rendering in the pages/posts/[slug].tsx
, So in this section we'll discuss the journey from writing the markdown files to rendering it as html file.
Libraries I've used to achieve this:
- gray-matter: Converts a string with front-matter to object.
- reading-time: How long an article will take to read.
- react-markdown: Markdown component for React using remark.
- react-syntax-highlighter: Syntax highlighting component for React.
- rehype-raw: parse the tree again (and raw nodes). Keeping positional info OK.
- remark-gfm: remark plugin to support GitHub Flavored Markdown.
- remark-lint: remark plugin to lint Markdown code style.
Now we've this folder ./blogs/
containing markdown files with front-matter and we need to convert these files to array of objects, sorted by date.
The below code snippet is to get the filenames.
// libs/posts.ts
const { serverRuntimeConfig } = getConfig();
const postsDirectory = path.join(serverRuntimeConfig.PROJECT_ROOT, "blogs");
const filenames = fs.readdirSync(postsDirectory);
Then I iterate over them to get the file contents using gray-matter
and its readingTime estimation using reading-time
// libs/posts.ts
const posts = filenames.map((filename: string) => {
const filePath = path.join(postsDirectory, filename);
const fileContents = fs.readFileSync(filePath, "utf8");
const { content, data, excerpt } = matter(fileContents, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line
excerpt: (file: any): void => {
file.excerpt = file.content
.split("\n")
.slice(
0,
site.post?.excerpt?.noOfLines ? site.post?.excerpt?.noOfLines + 1 : 4
)
.join(" ");
},
});
return {
content,
excerpt,
data,
fileName: path.parse(filePath).name,
readingTime: readingTime(content),
};
});
const postsSortedByDate = posts.sort(
(a, b) => +new Date(b.data.date) - +new Date(a.data.date)
);
At this point we've the list of posts as objects. That's super cool! Let's continue, Now we need a react component to render the post content in html page without breaking the styles/markup language.
To do this, I've created a component MarkdownWrapper
using react-markdown
wrapper, Here's what i've done:
- For render
img
tag, I replaced it bynext/image
component, to handle image optimization on blog post images, Also i just added some styles to force the responsiveness of the rendered images
// components/MarkdownRender/index.tsx
img({ src, alt }) {
return (
<Box width="100%" className="post-image-container">
<Image
src={src || ''}
alt={alt}
layout="fill"
className="image"
/>
</Box>
);
},
/* styles/globals.css */
.post-image-container {
width: 100%;
}
.post-image-container > div {
position: unset !important;
}
.post-image-container .image {
object-fit: contain;
width: 100% !important;
position: relative !important;
height: unset !important;
}
There's an important note here, Don't forget to update images domains in the next.config.js
file.
- For code rendering, I use
react-syntax-highlighter
andvscDarkPlus
theme like below
// components/MarkdownRender/index.tsx
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism';
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const lang = match?.[1] === 'js' ? 'javascript' : match?.[1];
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
PreTag="div"
showLineNumbers
showInlineLineNumbers
language={lang}
customStyle={{ marginBottom: '2rem' }}
>
{String(children).replace(/\n$/, '') || ''}
</SyntaxHighlighter>
) : (
<Code {...props}>{children}</Code>
);
},
That's all for this section. You can know more just looking at this file components/MarkdownRender/index.tsx
.
RSS and Newsletter
Okay, Here we have to do two things, first we need a rss.xml
file generated from our posts files, second a newsletter service to send my new emails to followers - I hope you're one of them - And this service is https://www.getrevue.co/ It's free and promote the newsletter to your twitter profile.
So first thing we need to do is generating the rss.xml
file, so we can list your blog posts as issues in your getrevue account.
To generate the rss.xml
I used feed
in generateRSSFeed()
located here libs/feed.ts
and calling it in a getStaticProps()
function located in pages/posts/index.tsx
to re-generate that file in build time.
After building and deploying the code I got this url https://www.zeyadetman.com/rss.xml with all posts metadata, Heading to https://www.getrevue.co/app/integrations/rss, And add the production url to ADD A FEED
input. This will reflect in the issues page on getrevue account.
I highly recommend enabling Show newsletter on Twitter profile https://www.getrevue.co/app/integrations/twitter.
Right now we've just configured the newsletter service and RSS feeds. Another thing you may noticed if you want to add their subscription form into your blog, It requires users to confirm subscription from their emails. You can disable this by using getrevue api with double_opt_in: false
creating a custom form.
I used react-hook-form
to create the form, Also created an api to handle the request
// pages/api/subscribers.ts
const subscribeHandler = async (
req: NextApiRequest,
res: NextApiResponse<Data>
): Promise<NextApiResponse<Data> | void> => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: "Email is required" });
}
const result = await fetch("https://www.getrevue.co/api/v2/subscribers", {
method: "POST",
headers: {
Authorization: `Token ${process.env.GETREVUE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, double_opt_in: false }), // to disable the confirmation email
});
const data = await result.json();
if (!result.ok) {
return res.status(500).json({ error: data.error.email[0] });
}
return res.status(201).json({ error: "" });
};
And used it simply like this
// components/Newsletter/index.tsx
const { status } = await fetch("/api/subscribers", {
method: "POST",
body: JSON.stringify({
email: data.email,
}),
headers: {
"Content-Type": "application/json",
},
});
To read more about handling the subscription form look at this file components/Newsletter/index.tsx
, Also to know more about handling https in the custom domains for your getrevue, Please read the deployment section.
Deployment
The final part is going production.
Until now I have implemented the app, and want to go production, I use vercel
it's the easist way to deploy your nextjs app.
Create new project, add the github repo of your project, don't forget to add the environment variables.
I'll keep this blog updated whenver I add a new feature to the blog. Just subscribe to the newsletter and I'll notify you via email.
Here's some resources that helped me:
- Custom Domains: This helps me link my godaddy custom domain to vercel deployment.
- How to make your custom domain secure with SSL: This helps me creating
https://feed.zeyadetman.com
and added it to getrevue custom domain. - How I Added an RSS Feed to My Next.js Site: This helped me generating the rss.xml page.
- How to Set up GoDaddy Domain with Vercel.