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:

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 by next/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 and vscDarkPlus 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:

Inspired Blogs: