Creating an Amazing Developer Portfolio using NeXuS Stack

September 29, 2023

Introduction

A portfolio website is a great way to showcase your skills and achievements to the world. It is a great way to show your potential to the recruiters and clients. In this tutorial, we will learn how to create an amazing developer portfolio website using NeXuS Stack.

You might be wondering what is NeXuS Stack? Well, I could not come up with a better acronym for my favorite web-development stack, comprising of Next.js, Xata & shadcn/ui.

I have been using this stack for a while now and I am in love with it. It provides a great developer experience and allows you to build amazing websites with ease.

So NeXuS stack stands for:

  • Ne: Next.js
  • X: Xata
  • uS: using shadcn/ui

Before jumping into the tutorial, you can first see a demo of the portfolio website we will be building in the following video:

You can also look at the live deployment link here

Now, let's talk about the key-components of our portfolio website:

Xata

Xata functions as a Serverless Data Platform, unifying the capabilities of a relational database, a search engine, and an analytics engine, all accessible through a unified and dependable REST API. It is a great tool for building great websites. It is fast, easy to use and has a great developer experience. I've been using it for a while now and I love its offerings and supportive community.

shadcn/ui

shadcn/ui is a UI library. It's a collection of re-usable components that you can copy and paste into your apps. It speeds up the development process and makes it easier to build beautiful websites, without having to worry about the design and styling in the first place.

Next.js

You are likely to be familiar with Next.js. It is a React framework for building websites. I see Next.js as a full-stack framework that allows you to build dynamic websites with ease, and it is a great fit for our portfolio website since it is fast, adaptable, and quite easy to use.

Talking about our NeXuS Developer Portfolio, we will have pages like Home Page, About Page, Projects Page, Experience Page, Skills Page, Contact Page, Blog Page, etc. To support dynamic data like the blog posts, their views, and to offer functionalities like creating and publishing blogs etc. we will use Xata. To make things more exciting, we will be making a custom admin panel for our portfolio website using Xata that will allow us to add new blog posts easily. We will also be creating a plugin that will allow us to select an image from our computer and get a URL for it that we can use in our blog posts. We will be utilizing the all new File Attachment offering of Xata for this, more about it later in the blog.

We will take it step by step and I will try to explain each and every step in detail. I will also be providing the code for each step in the GitHub repository, where you can check out the code for each step as a separate commit, making it easier for you to follow along.

So, let's get started!

First of all, let's create a new Next.js project.

We will be using Next.js 13.5 for this tutorial, paired with TypeScript & the app router.

You can do this by running the following command in your terminal:

npx create-next-app@latest --typescript nexus-developer-portfolio

This will create a new Next.js project.

Now let's move into the project directory and move forward with the tutorial:

cd nexus-developer-portfolio

Next up, let's get started with Xata. You can create a new Xata account here.

Once you have created your account, you will be redirected to the Xata dashboard.

Let's install the Xata CLI, which will allow us to interact with Xata from our terminal:

npm install -g @xata.io/cli

This will install the Xata CLI globally on your system.

Next, let's authenticate with the Xata CLI so that we can use it to interact with Xata from our terminal:

xata auth login

Follow the instructions and you will be logged in to the Xata CLI, easily.

We will get back to Xata back, and next, we will install the shadcn/ui library.

You can learn more about shadcn/ui here. Try to explore the library and you will find a lot of amazing components that you can use in your projects. Get familiar with the library and the ways you can install components from it.

For this tutorial, we will be using the shadcn/ui CLI to install the components. You can initialize shadcn/ui project by running the following command in your terminal:

npx shadcn-ui@latest init

You will be prompted with a few questions to configure your project, as per your liking.

Would you like to use TypeScript (recommended)? no / yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › › app/globals.css
Do you want to use CSS variables for colors? › no / yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › no / yes

Customize it as per your needs based on the prompts.

Once done, you will have a shadcn-ui configured project. Great!

Next, for the icons part, we will be using Lucide icons.

Lucide has a great collection of icons that you can use in your projects. You can explore the icons here.

npm install lucide-react

Now, let's get started with building stuff and we will install the required dependencies as we go along.

All the steps of this tutorial are available in the GitHub repository as different commits. You can follow along by cloning the repository and checking out the commits, or you can just follow along with the tutorial.

Creating the Navbar Component

Let's create a Navbar component. Carefully read the following steps and follow along, since for the upcoming components, I will not be explaining each and every step in detail, and I will be expecting you to follow along with the tutorial.

We will create a new file called Navbar.tsx in the components directory.

This file will contain the Navbar component.

Let's break down what we'll require in our Navbar component:

  • We will need a logo for our portfolio website. I have added two logo files to the public directory. You can use any logo you want. One of my logo goes well with the dark theme and the other one goes well with the light theme.

  • Since we will work with both dark and light themes, which we can toggle between right from the Navbar, we will be using the next-themes

npm i next-themes
  • Next, create a component-provider.tsx file in the components directory. This file will contain the theme provider from the next-themes library, which we will use to toggle between the light and dark themes and it will be responsible for the theme throughout our application.

The component-provider.tsx file will look like this:

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

Next, wrap the ThemeProvider component around the App component in the pages/_app.tsx file, as shown below:

import "@/styles/globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
  return (
    <>
      <ThemeProvider
        attribute="class"
        defaultTheme="system"
        enableSystem
        disableTransitionOnChange
      >
        <main className="max-w-screen-lg mx-auto">
          <Component {...pageProps} />
        </main>
      </ThemeProvider>
    </>
  );
}
  • Next, we will install the Button and Dropdown Menu components from shadcn/ui:
npx shadcn-ui@latest add button

and

npx shadcn-ui@latest add dropdown-menu

Now, the Navbar.tsx file will look like this:

import { AlignJustify, MoonStar, SunDim } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const Navbar = () => {
  const { setTheme } = useTheme();

  const navbarItems = [
    {
      name: "About",
      href: "/about",
    },
    {
      name: "Projects",
      href: "/projects",
    },
    {
      name: "Experience",
      href: "/experience",
    },
    {
      name: "Skills",
      href: "/skills",
    },
    {
      name: "Contact",
      href: "/contact",
    },
    {
      name: "Blog",
      href: "/blog",
    },
  ];
  return (
    <header>
      <div className="py-3 lg:px-2 px-6">
        <div className="flex h-16 items-center justify-between">
          <Link href="/">
            <div className="flex items-center justify-between">
              <span className="dark:hidden">
                <Image
                  src="/logo_light_mode.png"
                  alt="Logo"
                  width={40}
                  height={40}
                />
              </span>
              <span className="secondary-foreground:hidden">
                <Image
                  src="/logo_dark_mode.png"
                  alt="Logo"
                  width={40}
                  height={40}
                />
              </span>
            </div>
          </Link>
          <div className="md:hidden">
            <DropdownMenu>
              <DropdownMenuTrigger>
                <AlignJustify
                  size={24}
                  className="text-neutral-500 dark:text-secondary-foreground"
                />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                {navbarItems.map((item, index) => (
                  <DropdownMenuItem key={index}>
                    <Link
                      href={item.href}
                      className="text-neutral-500 transition hover:text-neutral-500/75 dark:text-secondary-foreground dark:hover:text-secondary-foreground/75"
                    >
                      {item.name}
                    </Link>
                  </DropdownMenuItem>
                ))}
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
          <div className="md:flex md:items-center md:gap-12 hidden">
            <nav aria-label="Global">
              <ul className="flex items-center gap-6 text-sm">
                {navbarItems.map((item, index) => (
                  <li key={index}>
                    <Link
                      href={item.href}
                      className="text-neutral-500 transition hover:text-neutral-500/75 dark:text-secondary-foreground dark:hover:text-secondary-foreground/75"
                    >
                      {item.name}
                    </Link>
                  </li>
                ))}
                <li className="mt-1.5">
                  <DropdownMenu>
                    <DropdownMenuTrigger>
                      <span className="dark:hidden">
                        <SunDim
                          size={20}
                          className="text-neutral-500 dark:text-secondary-foreground"
                        />
                      </span>
                      <span className="hidden dark:block">
                        <MoonStar
                          size={20}
                          className="text-neutral-500 dark:text-secondary-foreground"
                        />
                      </span>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <DropdownMenuItem onClick={() => setTheme("light")}>
                        Light
                      </DropdownMenuItem>
                      <DropdownMenuItem onClick={() => setTheme("dark")}>
                        Dark
                      </DropdownMenuItem>
                      <DropdownMenuItem onClick={() => setTheme("system")}>
                        System
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </li>
              </ul>
            </nav>
          </div>
        </div>
      </div>
    </header>
  );
};
export default Navbar;

Here, we are using the Dropdown Menu component from shadcn/ui. We are also using the Lucide icons for the menu icon and the theme toggle icon.

We are using the useTheme hook from next-themes to toggle between the light and dark themes.

So far, the Navbar is fully functional. You can add more items to the Navbar as per your needs.

Since Navbar is a global component, we will import it in the pages/_app.tsx file and wrap it around the Component component, as shown below:

import "@/styles/globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import type { AppProps } from "next/app";
import { Toaster } from "@/components/ui/toaster";
import Navbar from "@/components/navbar";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <ThemeProvider
        attribute="class"
        defaultTheme="system"
        enableSystem
        disableTransitionOnChange
      >
        <Toaster />
        <main className="max-w-screen-lg mx-auto">
          <Navbar />
          <Component {...pageProps} />
        </main>
      </ThemeProvider>
    </>
  );
}

Thus, this was this easy to create a Navbar component using shadcn/ui and next-themes, and integrate it with our Next.js project.

Creating the Hero Component

Next up, we will create a Hero component. We will create a new file called Hero.tsx in the components directory. Also, create a data.ts file in the lib directory.

This file will act as the middleware between the components and the data. We will store all the static data in this file.

After populating the data.ts file with sample data, it will look like this:

const user_data = {
  name: "John Doe",
  profile_image: "https://randomuser.me/api/portraits/men/62.jpg",
  bio: "Hi, I'm John Doe. I'm a software engineer at Google. I love to code and learn new things. I'm a big fan of React and TypeScript. I also love to play guitar and read books.",
  role: "Software Engineer",
  socialLinks: {
    twitter: "https://twitter.com/",
    linkedin: "https://www.linkedin.com/",
    github: "https://www.github.com/",
  },
  contactDetails: {
    email: "hello@example.com",
    phone: "+1 234 567 890",
  },
};
export default user_data;

This is all sample data. You can replace it with your own data.

Next, we will create the Hero component.

The skeleton of Hero component will look like this:

import user_data from "@/lib/data";

const Home = () => {
  return (
    <section>
      <div>{/* profile image */}</div>
      <div>{/* name */}</div>
      <div>{/* bio */}</div>
    </section>
  );
};

In the next step, we will add the profile image. We will use the Image component from Next.js to display the profile image. We will also use the Avatar component from shadcn/ui to display the profile image. The Avatar component is a wrapper around the Image component. It adds a border around the image and makes it look like an avatar. Avatar also has a fallback option that allows us to display a fallback text in case the image fails to load.

Now, as specified above, let's install the Avatar component from shadcn/ui:

npx shadcn-ui@latest add avatar

Let's move our hero section to a separate component called hero-section.tsx. The hero-section.tsx file will look like this:

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "./ui/button";
import Link from "next/link";

const HeroSection = ({
  name,
  bio,
  profile_image,
}: {
  name: string;
  bio: string;
  profile_image: string;
}) => {
  return (
    <div className="flex flex-col items-center justify-center text-center space-y-6 px-2">
      <div className="pt-24">
        <Avatar className="w-32 h-32 ring-4 dark:ring-white ring-black ring-offset-4">
          <AvatarImage src={profile_image} alt={name} />
          <AvatarFallback>
            {name
              .split(" ")
              .map((n) => n[0])
              .join("")}
          </AvatarFallback>
        </Avatar>
      </div>
      <div>
        <h1 className="font-bold lg:text-4xl text-2xl bg-gradient-to-br dark:from-gray-100 dark:via-gray-200 dark:to-gray-300 from-gray-400 via-gray-600 to-gray-800 bg-clip-text text-transparent">
          Hi, I&apos;m {name}!
        </h1>
      </div>
      <div>
        <p className="font-light max-w-2xl lg:text-xl text-lg">{bio}</p>
      </div>
      <div className="flex space-x-4 pt-6">
        <Link href="/about">
          <Button>More About Me</Button>
        </Link>
        <Link href="/blog">
          <Button>Read My Blog</Button>
        </Link>
      </div>
    </div>
  );
};

export default HeroSection;

And in the index.tsx file, we will import the HeroSection component and pass the required props to it:

import HeroSection from "@/components/hero-section";
import user_data from "@/lib/data";

const Home = () => {
  return (
    <section>
      <HeroSection
        name={user_data.name}
        bio={user_data.bio}
        profile_image={user_data.profile_image}
      />
    </section>
  );
};

Now, we have a minimal Hero component ready.

You can add more sections to it as per your needs. You can also add a theme toggle button to the Hero component. I have added it to the Navbar component.

Similarly we can create the About Me, Projects, Experience, Skills & dummy Contact pages, since they all require the user_data and as the data is static, we will store it in the data.ts file.

You can check out the code for these pages in the GitHub repository, it is pretty straightforward. Since explaining each and every step in detail will make this blog post very long, I will be expecting you to follow along with the tutorial and the code in the GitHub repository, since the code is pretty self-explanatory.

Let's jump to more exciting stuff. Welcome to the Dynamic Section of our portfolio website.

Creating the Blog, using Xata!

Now, let's create the Blog page.

We will create a new directory called blog in the pages directory.

Inside the blog directory, we will create a new file called index.tsx.

This file will contain the Blog page, where all the blog posts will be listed in order of their published date. Their published date, views, and other details will be fetched from our database and displayed on the page, on the fly.

Another file [id].tsx inside the blog directory will contain the blog post page, where a single blog post will be displayed. This is the dynamic route for the blog post page.

And now is the time to introduce Xata to our project. In the utils folder, there is a file called schema.json.

This file contains the schema for our Xata database for the Blog Post table, which we will be using to store our blog posts. In the later part of the tutorial, we will also be using Xata to store the contact form submissions, since in earlier portion of the tutorial, we left the contact form to be static, but for now, let's focus on the blog.

The schema looks like:

{
  "tables": [
    {
      "name": "Posts",
      "columns": [
        {
          "name": "title",
          "type": "string"
        },
        {
          "name": "slug",
          "type": "string"
        },
        {
          "name": "description",
          "type": "string"
        },
        {
          "name": "published",
          "type": "datetime"
        },
        {
          "name": "views",
          "type": "int"
        },
        {
          "name": "body",
          "type": "text"
        }
      ]
    }
  ]
}

The schema is pretty straightforward. It contains a table called Posts.

The Posts table contains the following columns:

  • title: The title of the blog post.
  • slug: The slug of the blog post.
  • description: The description of the blog post.
  • published: The date on which the blog post was published.
  • views: The number of views of the blog post.
  • body: The body of the blog post.

We will store the body as markdown in a string format, and will later parse it to HTML using the remark library, allowing us to use markdown in our blog posts.

Let's use the Xata CLI to create our database.

Run the following command in your terminal:

xata init --schema=lib/schema.json

This will prompt you to select a workspace.

Select the workspace you want to use. If you don't have a workspace, you can create one as well.

Then, create a new database. You can name it whatever you want. I have named it nexus-developer-portfolio.

With the database schema in place, the final step is to generate the code that allows you to access and query the data from application. To do this, run:

xata pull main

And done! We have created our database. It is that easy to create a database using Xata. You can check out the database in the Xata UI as well.

Next, let's install some dependencies that we will require to work with markdown content, Tailwind CSS and our frontend framework, Next.js.

npm install remark remark-html axios @tailwindcss/typography

Here, we are using the remark library to convert markdown to HTML.

We are using the axios library to make HTTP requests.

We are using the @tailwindcss/typography library to style the markdown content, by utilizing the prose class from Tailwind CSS, which is a great way to style markdown content.

Inside our pages/blog/index.tsx file, we will fetch the blog posts from our database and will display them.

We will also have a search bar that will allow us to search for blog posts, powered by Xata's search functionality. When you insert data into a Xata database, it is automatically indexed for full-text search. So we don't need to change any configuration to enable search, it is enabled by default and is pretty easy to use.

Inside the pages/blog/[id].tsx file, we will fetch the blog post from our database and display it.

We will also have a view counter that will allow us to count the number of views of a blog post.

We will be using Xata's update functionality to update the views of a blog post, every time a user visits the blog post.

So, after the complete workaround, we have 4 new API routes:

  • /api/blog/getPost: This route will fetch a blog post from our database, based on the query parameter with id as the key.

  • /api/blog/getPosts: This route will fetch all the blog posts from our database, to display them on the blog page.

  • /api/blog/searchPost: This route will search for blog posts in our database, based on the query parameter with query as the key. We use the Xata's search functionality to search for blog posts.

  • /api/blog/updateViews: This route will update the views of a blog post in our database, based on the query parameter with id as the key. We use the Xata's update functionality to update the views of a blog post.

Now, our blog is ready. The pages/blog/index.tsx file will look like this:

import React, { useEffect, useState } from "react";
import axios from "axios";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import Link from "next/link";

interface Post {
  published: string;
  slug: string;
  title: string;
  description: string;
  body: string;
  views: number;
}

const BlogPage = () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [searchParams, setSearchParams] = useState({
    q: "",
  });

  const getPosts = async () => {
    try {
      const { data } = await axios.get<Post[]>("/api/getPosts");
      console.log(data);
      setPosts(data);
    } catch (error) {
      console.error("Error fetching posts:", error);
    }
  };

  const searchPosts = async () => {
    try {
      const { data } = await axios.get<Post[]>("/api/searchPost", {
        params: {
          query: searchParams.q,
        },
      });
      console.log(data);
      setPosts(data);
    } catch (error) {
      console.error("Error searching posts:", error);
    }
  };

  useEffect(() => {
    if (searchParams.q === "") {
      getPosts();
    } else {
      searchPosts();
    }
  }, [searchParams.q]);

  useEffect(() => {
    getPosts();
  }, []);

  return (
    <section className="px-2">
      <div className="mt-8">
        <form>
          <Input
            name="q"
            defaultValue={searchParams.q}
            placeholder="Search blog posts..."
            onChange={(e) =>
              setSearchParams({ ...searchParams, q: e.target.value })
            }
          />
        </form>
      </div>
      <div className="mt-8">
        {posts.length === 0 && <p>No blog posts found</p>}
        {posts.map((post, index) => (
          <Card key={index} className="mb-16 p-3 my-2">
            <p className="text-xs mb-2 text-secondary-foreground">
              {new Date(post.published).toLocaleDateString("en-US", {
                weekday: "long",
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
              &bull; {post.views} views
            </p>
            <h2 className="text-2xl text-primary">
              <Link href={`blog/${post.slug}`}>{post.title}</Link>
            </h2>
            <p className="text-secondary-foreground/70 mb-5">
              {post.description}
            </p>
            <Link href={`blog/${post.slug}`}>
              <Button>Read more &rarr;</Button>
            </Link>
          </Card>
        ))}
      </div>
    </section>
  );
};

export default BlogPage;

Here, we are using the Card component from shadcn/ui to display the blog posts. We are also using the Input component from shadcn/ui to display the search bar. We are using the Link component from Next.js to link to the blog post page. The data returned from the API routes is in JSON format, which we are parsing and displaying on the page, on the fly.

And the pages/blog/[id].tsx file will look like this:

import { Button } from "@/components/ui/button";
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { remark } from "remark";
import html from "remark-html";

interface Post {
  id: string;
  title: string;
  published: string;
  description: string;
  body: string;
  views: number;
}

const DyanmicBlogPostPage = () => {
  const router = useRouter();
  const { id } = router.query;
  const [post, setPost] = useState<Post | null>(null);
  const [markdownBody, setMarkdownBody] = useState<string>("");
  const [views, setViews] = useState<number>(post?.views || 0);
  const getPost = async () => {
    try {
      const { data } = await axios.get<Post>("/api/getPost", {
        params: {
          id,
        },
      });
      const result = await remark().use(html).process(data.body);
      setMarkdownBody(result.toString());
      setPost(data);
    } catch (error) {
      console.error("Error fetching post:", error);
    }
  };
  const updateViews = async () => {
    try {
      await axios.post("/api/updateViews", {
        id: post?.id,
        views: post?.views,
      });
      setViews(views + 1);
    } catch (error) {
      console.error("Error updating views:", error);
    }
  };

  useEffect(() => {
    if (id) {
      getPost();
    }
  }, [id]);

  useEffect(() => {
    if (post) {
      updateViews();
    }
  }, [post]);
  return (
    <>
      <Link href="/blog">
        <Button className="px-2" variant={"outline"}>
          &larr; Go Back
        </Button>
      </Link>
      <div className="mt-6 px-2">
        {post ? (
          <div className="pb-24">
            <h1 className="text-3xl mb-2">{post.title}</h1>
            <p className="text-sm mb-4 text-secondary-foreground">
              {new Date(post.published).toLocaleDateString("en-US", {
                day: "numeric",
                month: "long",
                year: "numeric",
              })}{" "}
              • {post.views} views
            </p>
            <article
              dangerouslySetInnerHTML={{ __html: markdownBody }}
              className="prose dark:prose-invert max-w-none mt-10"
            />
          </div>
        ) : (
          <p>Loading...</p>
        )}
      </div>
    </>
  );
};

export default DyanmicBlogPostPage;

Congratulations! We have created our blog.

For now, you can use the Xata UI to add new blog posts. But we will be creating a custom admin panel for our portfolio website using Xata that will allow us to add new blog posts easily.

Completing the Contact Form, making it dynamic using Xata!

Do you remember we left the Contact Form to be static at the beginning of the tutorial?

Well, now is the time to make it dynamic.

We will be using Xata to store the contact form submissions.

First of all, let's create a new table in our Xata database.

The schema for the table looks like:

 {
      "name": "Contact",
      "columns": [
        {
          "name": "name",
          "type": "string"
        },
        {
          "name": "email",
          "type": "email"
        },
        {
          "name": "message",
          "type": "text"
        }
      ]
    }

As mentioned above, you can use the Xata CLI to create the table.

In the commit relating to this section, I have already extended the schema to include the Contact table.

First of all, delete the existing database from the Xata UI. You can download the existing data as a CSV and upload it again in the new database, directly in the Xata UI in case you'd already added some data.

Then, run the following command in your terminal:

xata init --schema=lib/schema.json

And again, select the workspace you want to use.

Then, create a new database. You can name it whatever you want. I have named it nexus-developer-portfolio, as I did before.

Now, you will have two tables in your database. The Posts table and the Contact table.

For the contact form section, we will be installing the toast component from shadcn/ui, in order to display a toast message when the form is submitted or when there is an error.

You might be able to figure out the way to install the toast component from shadcn/ui by now.

The pages/contact.tsx file will look like this:

import { Textarea } from "@/components/ui/textarea"
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import axios from 'axios';
import { useToast } from "@/components/ui/use-toast"
import { useState } from 'react';
import { Loader } from 'lucide-react';

const formSchema = z.object({
    name: z.string().min(2).max(50),
	@@ -14,6 +18,8 @@ const formSchema = z.object({


const ContactPage = () => {
    const { toast } = useToast()
    const [loading, setLoading] = useState(false)
    const form = useForm<z.infer<typeof formSchema>>({
        resolver: zodResolver(formSchema as any),
        defaultValues: {
	@@ -23,8 +29,20 @@ const ContactPage = () => {
        },
    });

    async function onSubmit(values: z.infer<typeof formSchema>) {
        setLoading(true)
        const body = {
            name: values.name,
            email: values.email,
            message: values.message,
        };
        await axios.post('/api/contactMessage', body);
        setLoading(false)
        toast({
            title: "Message Sent",
            description: "Thank you for reaching out to me. I will get back to you as soon as possible.",
        })
        form.reset()
    }

    return (
	@@ -80,7 +98,14 @@ const ContactPage = () => {
                            <span className="text-red-300 text-sm">{form.formState.errors.message.message}</span>
                        )}
                    </div>
                    <Button disabled={loading} type="submit">
                        <span className={`${!loading ? 'inline-block' : 'hidden'}`}>
                            Send
                        </span>
                        <span className={`${loading ? 'inline-block' : 'hidden'}`}>
                            <Loader className="animate-spin" />
                        </span>
                    </Button>
                </form>
            </Card>
        </section>
    );

Here, we are using the useForm hook from react-hook-form to handle the form state. We are also using the zod library to validate the form. We are using the useToast hook from shadcn/ui to display a toast message when the form is submitted or when there is an error. We are using the Loader component from Lucide to display a loader when the form is being submitted.

The API route for the contact form will look like this:

import { getXataClient } from "@/lib/xata";
import type { NextApiRequest, NextApiResponse } from "next";

const xata = getXataClient();

async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!req.body.name || !req.body.email || !req.body.message) {
    res.status(400).json({ error: "Missing name, email, or message" });
    return;
  }
  const record = await xata.db.Contact.create({
    name: req.body.name,
    email: req.body.email,
    message: req.body.message,
  });
  res.status(200).json(record);
}

export default handler;

Here, we are using the Xata's create functionality to create a new record in the Contact table. We are also validating the request body to make sure that the required fields are present.

And done! We have made our contact form dynamic. You can check out the submission in the Xata UI, in the Contact table in your database, whenever a visitor submits the contact form. Cool stuff!

Creating a Custom Admin Panel for our Portfolio Website

Next up, we will create a custom admin panel for our portfolio website using Xata that will allow us to add new blog posts easily, directly from our portfolio website, but with complete security.

At the /admin route, we will have a prompt that will ask us to enter the password. If the password is correct, we will be shown the admin panel. If the password is incorrect, we will be shown an error message. The password of the admin panel will be stored in the .env.local file, so that it is not exposed to the public.

When logged in, we will be able to add new blog posts. When we click on the Add New Blog Post button, we will be shown two sections, the one on the left is to type the blog post content and the one on the right is to preview the blog post, in real-time. You can use the markdown syntax to format the blog post content.

Once we are done writing the blog post, we can click on the Publish button to publish the blog post. Then we can add details like the title, description, slug, etc. in a popup.

Once we are done adding the details, we can click on the Publish button to publish the blog post.

The two API routes that we will be using for this are:

  • /api/verifyAdmin: This route will check if the password entered by the user is correct or not. If the password is correct, it will return a true token. If the password is incorrect, it will return a false token. Based on the token, we will show the admin panel or the error message.

  • /api/newBlogPost: This route will add a new blog post to our database. It will take the blog post content as the request body and add it to our database, in the Posts table. It looks like:

import { getXataClient } from "@/lib/xata";
import type { NextApiRequest, NextApiResponse } from "next";

const xata = getXataClient();

async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const records = await xata.db.Posts.filter("slug", req.body.slug)
      .select(["slug"])
      .getFirst();

    if (records) {
      res.status(200).json({
        success: false,
        message: "Slug already exists",
      });
      return;
    }
    await xata.db.Posts.create({
      title: req.body.title,
      slug: req.body.slug,
      description: req.body.description,
      published: new Date(),
      views: 0,
      body: req.body.body,
    });
    res.status(200).json({
      success: true,
    });
  } catch (error) {
    res.status(200).json({
      success: false,
      message: "Something went wrong...",
    });
  }
}

export default handler;

And done! We have created a custom admin panel for our portfolio website using Xata that will allow us to add new blog posts easily.

Creating a Image Plugin for the Markdown Editor

Next thing, let's create a image plugin for the markdown editor.

The motivation behind creating this plugin is that we want to be able to add images to our blog posts.

But you cannot add images to a markdown editor by default.

And uploading an image to a third-party service and then adding the image to the blog post is a tedious task.

So, we will be creating a plugin that will allow us to upload images to our Xata database and then add them to our blog posts, directly from the markdown editor present in the admin panel of our portfolio website.

Just along to the Publish Button, we will have an Image Plugin button.

When we click on the Image Plugin button, we will be shown a popup. The popup will contain an input field to upload the image and a button to upload the image and get a URL back for the image.

When we click on the Upload Image button, the image will be uploaded to our Xata database and we will get a URL back for the image.

We can then copy the direct URL of the image and paste it in the markdown editor wherevever we want, or even we can copy the markdown syntax for the image and paste it in the markdown editor.

And done! The image will be added to the blog post.

The schema for the table for image plugin is like: https://eu-west-1.storage.xata.sh/0t81np4sid34j7s2iultqn28u4

The API route that we will be using for this is:

  • /api/uploadImage: This will upload the image to our Xata database and return the URL of the image. It looks like:
import { getXataClient } from "@/lib/xata";
import type { NextApiRequest, NextApiResponse } from "next";

const xata = getXataClient();

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const base64Image = req.body.base64.split(";base64,").pop();
  const record = await xata.db.ImageUploadPlugin.create({
    name: req.body.name,
    image_file: {
      name: req.body.name,
      mediaType: "image/png",
      base64Content: base64Image,
    },
  });
  res.status(200).json(record);
}

Here, we are using the Xata's create functionality to create a new record in the ImageUploadPlugin table.

We are also validating the request body to make sure that the required fields are present.

This is made possible by the Xata's all new File Attachment feature, more about it here.

Also, as an enhanced feature, we can store the draft of the blog post user is writing in the local storage.

So, if the user accidentally closes the tab, the draft will be saved and the user can continue writing the blog post from where he left off.

And done! We have created a image plugin for the markdown editor.

🚀 Deployment

Now, we are ready to deploy our portfolio website. We will be deploying our portfolio website on Vercel. You can deploy your portfolio website on any other platform as well.

Just make sure to add the following environment variables in your Vercel project:

XATA_BRANCH= main or your branch name
XATA_API_KEY=xau_*******
ADMIN_PASSWORD= ********

And done! We have deployed our portfolio website.

So, this was it. We have created a portfolio website using Xata. We have also created a custom admin panel for our portfolio website using Xata that will allow us to add new blog posts easily. We have also created a image plugin for the markdown editor. We have also deployed our portfolio website on Vercel.

You can check out the complete code for this project on GitHub here.

It was a great experience building this project. I hope you enjoyed this tutorial. If you have any questions, feel free to reach out to me on Twitter

Thank you for reading this far! Have a great day ahead! 😄