DEV Community

Cover image for Building a Kanban Board with Next.js,Vercel AI and Tolgee
Arindam Majumder Subscriber for Tolgee

Posted on

Building a Kanban Board with Next.js,Vercel AI and Tolgee

TL;DR

In this article, we will build a real-time Kanban board in Next.js using WebSockets, with database support, AI support through the Vercel AI SDK and localization via Tolgee.

What You Will Learn: โœจ

  • Set up a WebSocket server in Next.js without Express.
  • Implement credentials-based authentication in Next.js with NextAuth.
  • Configure a PostgreSQL database using Docker or a cloud provider.
  • Integrate AI support for task descriptions with the Vercel AI SDK.
  • Add real-time translation and localization using Tolgee.

Star the Tolgee repository โญ

Are you ready to build a unique Kanban board with AI and localization support? ๐Ÿ”ฅ

Ready GIF


Setting up the Project ๐Ÿ› ๏ธ

Initializing a Next.js Application

Initialize a new Next.js application with the following command:

โ„น๏ธ You can use any package manager of your choice. For this project, I will use npm.

npx create-next-app@latest kanban-ai-realtime-localization --typescript --tailwind --eslint --app --src-dir --use-npm

Enter fullscreen mode Exit fullscreen mode

Next, navigate into the newly created Next.js project:

cd kanban-ai-realtime-localization

Enter fullscreen mode Exit fullscreen mode

Installing Dependencies

Weโ€™ll need several dependencies. Run this command to install all the dependencies required for our project:

npm install @ai-sdk/openai @tolgee/react @tolgee/web @tolgee/format-icu @tanstack/react-query @prisma/client ai socket.io socket.io-client prisma next-auth date-fns nodemon ts-node zod tsconfig-paths react-beautiful-dnd

Enter fullscreen mode Exit fullscreen mode

Setting Up UI Components

For UI components, we will use shadcn/ui. Initialize it with default settings with this command:

npx shadcn@latest init -d

Enter fullscreen mode Exit fullscreen mode

Now, letโ€™s add some UI components that we are going to use in our application later on. To add reusable components from shadcn/ui, run this command:

npx shadcn@latest add button card input label select textarea toast

Enter fullscreen mode Exit fullscreen mode

Inside the app/components/ui directory, some additional files will be added for these components, which we will use when building the UI for our application.


Setting up the Database Model ๐Ÿ“ฆ

Initializing Prisma

Initialize Prisma with the following command:

npx prisma init

Enter fullscreen mode Exit fullscreen mode

After you run this command, a new schema.prisma file should be created in the prisma directory at the root of your project.

Defining the Prisma Schema

Modify the newly created schema.prisma file to use PostgreSQL as the database and include the User and Task models.

// ๐Ÿ‘‡ prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: <https://pris.ly/d/prisma-schema>

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: <https://pris.ly/cli/accelerate-init>

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id       String @id @default(cuid())
  email    String @unique
  password String

  tasks Task[] @relation("UserTasks")

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Task {
  id          String  @id @default(cuid())
  title       String
  description String?
  userId      String

  column Int
  order  Int

  createdBy User @relation("UserTasks", fields: [userId], references: [id])

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Enter fullscreen mode Exit fullscreen mode

The model is straightforward: each user can have multiple tasks, with each task linked to a specific user. A task has an integer column value representing its status (0 for ongoing, 1 for pending, and 2 for completed). The order value determines each task's position within its assigned column.

Now that we have our model ready, we need to push it to our database. For this, we need the connection URL.

If you already have access to a database with Neon or another service, that's great. Populate the .env file with the connection URL. You don't need to set up the database locally with docker.


Setting up a Database Locally with Docker ๐Ÿณ

If you're following along and just want to try the project with a local PostgreSQL database using Docker, add a new variable named DATABASE_URL with this connection string value to the .env file.

// ๐Ÿ‘‡ .env

# If you are using local DB with docker
DATABASE_URL=postgresql://postgres:password@localhost:5432/kanban-board

Enter fullscreen mode Exit fullscreen mode

To run a database locally, make sure you have Docker installed. Create a new directory named scripts in the root of the project and add a file called start-local-db-docker.sh with the following lines of code:

# ๐Ÿ‘‡ scripts/start-local-db-docker.sh

#!/usr/bin/env bash

# place this in .env: DATABASE_URL=postgresql://postgres:password@localhost:5432/kanban-board
DB_CONTAINER_NAME="kanban-board"

if ! [ -x "$(command -v docker)" ]; then
  echo -e "Docker is not installed. Please install docker and try again."
  exit 1
fi

if [ "$(docker ps -q -f name=$DB_CONTAINER_NAME)" ]; then
  echo "Database container '$DB_CONTAINER_NAME' is already running"
  exit 0
fi

if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
  docker start "$DB_CONTAINER_NAME"
  echo "Existing database container '$DB_CONTAINER_NAME' has been started"
  exit 0
fi

# import env variables from .env
set -a
source ../.env

# Extract components from DATABASE_URL
PROTO="$(echo $DATABASE_URL | grep :// | sed -e's,^\\(.*://\\).*,\\1,g')"
URL="$(echo ${DATABASE_URL/$PROTO/})"
USERPASS="$(echo $URL | grep @ | cut -d@ -f1)"
HOSTPORT="$(echo ${URL/$USERPASS@/} | cut -d/ -f1)"
DB_HOST="$(echo $HOSTPORT | cut -d: -f1)"
DB_PORT="$(echo $HOSTPORT | cut -d: -f2)"
DB_USER="$(echo $USERPASS | cut -d: -f1)"
DB_PASSWORD="$(echo $USERPASS | cut -d: -f2)"

# Debugging information
echo "Extracted DB_HOST: $DB_HOST"
echo "Extracted DB_PORT: $DB_PORT"
echo "Extracted DB_USER: $DB_USER"
echo "Extracted DB_PASSWORD: $DB_PASSWORD"

if [ "$DB_PASSWORD" = "password" ]; then
  echo "You are using the default password"
  read -p "Should we generate a random password for you? [y/N]: " -r REPLY
  if [[ $REPLY =~ ^[Yy]$ ]]; then
    # Generate a random URL-safe password
    DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
    sed -i -e "s#:password@#:$DB_PASSWORD@#" ../.env
  else
    echo "Please set a password in the `.env` file and try again"
    exit 1
  fi
fi

echo "Starting the container on port $DB_PORT"

docker run -d \\
  --name $DB_CONTAINER_NAME \\
  -e POSTGRES_USER="$DB_USER" \\
  -e POSTGRES_PASSWORD="$DB_PASSWORD" \\
  -e POSTGRES_DB="$DB_CONTAINER_NAME" \\
  -p "$DB_PORT:5432" \\
  docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"

Enter fullscreen mode Exit fullscreen mode

This script basically reads the .env file for the DATABASE_URL variable and extracts all the relevant data like the username, password, the database name and creates a container if it does not exist. If it already does, it simply spins up the existing container.

Run this script to create and run a PostgreSQL container which will host all the user data for our application.

bash scripts/start-local-db-docker.sh

Enter fullscreen mode Exit fullscreen mode

Now, we should have a running container with PostgreSQL. You can check if that is the case by running this command:

docker ps | grep "kanban-board"

Enter fullscreen mode Exit fullscreen mode

Now, we will need a way to instantiate a Prisma client to interact with the database.

Create a new file index.ts inside the src/db directory and add the following lines of code:

// ๐Ÿ‘‡ src/db/index.ts

import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

export const db = globalThis.prismaGlobal ?? prismaClientSingleton();

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = db;

Enter fullscreen mode Exit fullscreen mode

We set up a singleton instance of the PrismaClient to ensure only one instance is created and reused across your application, especially helpful in development mode.

We can now use our exported constant db to interact with our database in our application.

Run the following command to push your changes in your schema to the database.

npx prisma db push

Enter fullscreen mode Exit fullscreen mode

Now, to have the updated types work in the IDE, run the following command to generate new types based on our updated schema.

npx prisma generate

Enter fullscreen mode Exit fullscreen mode

This is all that we need to set up our application database. ๐Ÿฅณ


Setting up Tolgee for Localization ๐Ÿ—ฃ๏ธ

To enable localization in your Next.js application with Tolgee, follow these steps:

  1. Create language.ts

This file handles language detection and cookie management.

// ๐Ÿ‘‡ src/tolgee/language.ts

"use server";

import { detectLanguageFromHeaders } from "@tolgee/react/server";
import { cookies, headers } from "next/headers";
import { ALL_LANGUAGES, DEFAULT_LANGUAGE } from "@/tolgee/shared";

const LANGUAGE_COOKIE = "NEXT_LOCALE";

export async function setLanguage(locale: string) {
  const cookieStore = cookies();
  cookieStore.set(LANGUAGE_COOKIE, locale, {
    // One year
    maxAge: 1000 * 60 * 60 * 24 * 365,
  });
}

export async function getLanguage() {
  const cookieStore = cookies();
  const locale = cookieStore.get(LANGUAGE_COOKIE)?.value;
  if (locale && ALL_LANGUAGES.includes(locale)) {
    return locale;
  }

  // Try to detect language only if in a browser environment
  if (typeof window !== "undefined") {
    const detected = detectLanguageFromHeaders(headers(), ALL_LANGUAGES);
    return detected || DEFAULT_LANGUAGE;
  }

  return DEFAULT_LANGUAGE;
}

Enter fullscreen mode Exit fullscreen mode

The setLanguage function saves the selected language (locale) as a cookie with a one-year expiry, allowing the app to remember the user's language preference across sessions.

The getLanguage function checks for the saved language in cookies. If a valid language is found, it returns that; otherwise, it attempts to detect the language from the browser's headers if running in a browser. If detection fails or the environment isn't a browser, it defaults to DEFAULT_LANGUAGE.

  1. Create shared.ts

This file contains shared constants and functions for handling localization, including fetching static data for translations

// ๐Ÿ‘‡ src/tolgee/shared.ts

import { FormatIcu } from "@tolgee/format-icu";
import { DevTools, Tolgee } from "@tolgee/web";

const apiKey = process.env.TOLGEE_API_KEY;
const apiUrl = process.env.TOLGEE_API_URL;

export const ALL_LANGUAGES = ["en", "cs", "de", "fr"];

export const DEFAULT_LANGUAGE = "en";

export async function getStaticData(
  languages: string[],
  namespaces: string[] = [""],
) {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const result: Record<string, any> = {};
  for (const lang of languages) {
    for (const namespace of namespaces) {
      if (namespace) {
        result[`${lang}:${namespace}`] = (
          await import(`../../messages/${namespace}/${lang}.json`)
        ).default;
      } else {
        result[lang] = (await import(`../../messages/${lang}.json`)).default;
      }
    }
  }
  return result;
}

export function TolgeeBase() {
  return Tolgee().use(FormatIcu()).use(DevTools()).updateDefaults({
    apiKey,
    apiUrl,
    fallbackLanguage: "en",
  });
}

Enter fullscreen mode Exit fullscreen mode

The getStaticData function is responsible for loading translations for specific languages and namespaces to prefetch localized content. It fetches JSON files from messages directory, by language and namespace, and then bundles everything into a single object and returns it.

For language selection in our application, we will provide the user with four different language choices (English, Czech, French and German). You can add support to other languages if you like.

Inside the messages directory at the root of the project, we will store different static data for different words and sentences.

โ„น๏ธ You can find link to these static translation file in my repository. There is nothing to explain in that file as they are bunch of translation sentences in different other languages.

The TolgeeBase function sets up Tolgee with tools for handling translations. It adds support for ICU message formatting (FormatIcu) and includes DevTools for debugging. The function uses the API key and URL from environment variables and sets English (en) as the fallback language.

  1. Update Environment Variables

We are using two different env variables, populate the .env file with these API keys. Sign up for an account in Tolgee and get access to the TOLGEE_API_KEYS, but for this application, it is not required to have that API key.

// ๐Ÿ‘‡ .env

TOLGEE_API_URL=https://app.tolgee.io

# Optional
TOLGEE_API_KEY=

Enter fullscreen mode Exit fullscreen mode
  1. Create server.ts

This file configures the Tolgee instance for server-side rendering, setting up translation handling.

// ๐Ÿ‘‡ src/tolgee/server.ts

import { TolgeeBase, ALL_LANGUAGES, getStaticData } from "@/tolgee/shared";
import { createServerInstance } from "@tolgee/react/server";
import { getLanguage } from "@/tolgee/language";

export const { getTolgee, getTranslate, T } = createServerInstance({
  getLocale: getLanguage,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  createTolgee: async (locale: any) =>
    TolgeeBase().init({
      // including all locales
      // on server we are not concerned about bundle size
      staticData: await getStaticData(ALL_LANGUAGES),
      observerOptions: {
        fullKeyEncode: true,
      },
      language: locale,
      fetch: async (input, init) =>
        fetch(input, { ...init, next: { revalidate: 0 } }),
    }),
});

Enter fullscreen mode Exit fullscreen mode

This code creates a Tolgee instance for server-side translation handling. It starts by setting getLocale to use the getLanguage function, which retrieves the userโ€™s preferred language. Then, in createTolgee, it initializes Tolgee with translation data for all supported languages through getStaticData.

It also sets Tolgee to use the provided language (from getLanguage) and configures a custom fetch function to always load fresh data by setting revalidate: 0, preventing caching of translation requests.

  1. Create client.ts

This sets up the Tolgee provider for client-side rendering.

// ๐Ÿ‘‡ src/tolgee/client.ts

"use client";

import { TolgeeBase } from "@/tolgee/shared";
import { TolgeeProvider, TolgeeStaticData } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

type Props = {
  language: string;
  staticData: TolgeeStaticData;
  children: React.ReactNode;
};

const tolgee = TolgeeBase().init();

export const TolgeeProviderClient = ({
  language,
  staticData,
  children,
}: Props) => {
  const router = useRouter();

  useEffect(() => {
    const { unsubscribe } = tolgee.on("permanentChange", () => {
      router.refresh();
    });
    return () => unsubscribe();
  }, [router]);

  return (
    <TolgeeProvider
      tolgee={tolgee}
      options={{ useSuspense: false }}
      fallback="Loading..."
      ssr={{
        language,
        staticData,
      }}
    >
      {children}
    </TolgeeProvider>
  );
};

Enter fullscreen mode Exit fullscreen mode

This code sets up a client-side Tolgee provider for translations. TolgeeProviderClient takes language, staticData, and children as props, and it initializes Tolgee with the specified language and data. Inside useEffect, it listens for language changes with permanentChange, refreshing the page through router.refresh() whenever the language updates.

Finally, TolgeeProvider renders the children, using ssr options to preload translations and displaying "Loading..." if translations arenโ€™t ready instantly.

  1. Wrap the Application with TolgeeProviderClient in layout.tsx

Finally, wrap your application with the <TolgeeProviderClient /> component to ensure all translations are accessible.

// ๐Ÿ‘‡ src/app/layout.tsx

import type { Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
import { Navbar } from "@/components/navbar";
import { getLanguage } from "@/tolgee/language";
import { getStaticData } from "@/tolgee/shared";
import { TolgeeProviderClient } from "@/tolgee/client";

// Rest of the code...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const locale = await getLanguage();

  const staticData = await getStaticData([locale, "en"]);
  return (
    <html lang={locale}>
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          <TolgeeProviderClient language={locale} staticData={staticData}>
            <Navbar />
            {children}
            <Toaster />
          </TolgeeProviderClient>
        </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

Here first we are getting access to the locale of the user based on the header or from the cookie which we set from the function. Then we provide that locale to the <html /> tag.

That is all we need to set up Tolgee in our Next.js application. โœจThis is going to be a standard process that you need to do to implement location with Tolgee in any Next.js applications.


Setting up Authentication ๐Ÿ›ก๏ธ

We will be using NextAuth for authentication in our application. First, letโ€™s start by defining a new Zod schema which we will use to validate the user-passed data.

Zod Schema for Validation

Define a Zod schema (AuthSchema) to validate user input for email and password during login and registration. This ensures the email format is correct and the password meets specified length requirements.

// ๐Ÿ‘‡ src/lib/validators/auth.ts

import { z } from "zod";

export const AuthSchema = z.object({
  email: z.string().email(),
  password: z.string().trim().min(8).max(20),
});

export type TAuthSchema = z.infer<typeof AuthSchema>;

Enter fullscreen mode Exit fullscreen mode

We require the email field to be the exact email and not any other string and we want the password field to be a minimum length of 8 characters and a max length of 20 characters. We will use this validation schema in multiple places to validate the user-passed data in our login/register form to check if it meets the criteria.

NextAuth Configuration

You set up NextAuth in route.ts under src/app/api/auth/[...nextauth], using CredentialsProvider for authentication. The authorize function validates the credentials, checks the user's existence, and verifies the password.

// ๐Ÿ‘‡ src/app/api/auth/[...nextauth]/route.ts

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcrypt";
import { AuthSchema, TAuthSchema } from "@/lib/validators/auth";
import { db } from "@/db";

const handler = NextAuth({
  session: {
    strategy: "jwt",
  },
  pages: {
    signIn: "/login",
  },
  secret: process.env.NEXTAUTH_SECRET,
  providers: [
    CredentialsProvider({
      name: "Credentials",
      // These are used in the default sign-in page from next-auth.
      credentials: {
        email: {
          label: "Email",
          type: "text",
          placeholder: "example@gmail.com",
        },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const payload: TAuthSchema | null =
          credentials?.email && credentials?.password
            ? {
                email: credentials.email,
                password: credentials.password,
              }
            : null;

        if (!payload) return null;

        const validatedFields = AuthSchema.safeParse(payload);

        if (!validatedFields.success) return null;

        const { email: userInputEmail, password: userInputPassword } =
          validatedFields.data;

        const potentialUser = await db.user.findUnique({
          where: {
            email: userInputEmail,
          },
        });

        if (!potentialUser) return null;

        const isCorrectPassword = await compare(
          userInputPassword,
          potentialUser.password,
        );

        if (!isCorrectPassword) return null;

        //Because getting the error in the IDE: _ is assigned a value but never used.
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { password: _, ...userWithoutPassword } = potentialUser;

        return userWithoutPassword;
      },
    }),
  ],
});

export { handler as GET, handler as POST };

Enter fullscreen mode Exit fullscreen mode

The authorize function logic is responsible for logging in the user or not. The function in this setup checks if the provided email and password match an existing user in the database.

We are using only the Credential based authentication. First, it validates the credentials using the AuthSchema for field validation. If validation succeeds, it looks up the user by email in the database. If the user is found, it then compares the hashed password in the database with the password input. If both checks pass, it returns the user's data (excluding the password).

As you might have guessed, here we require NEXTAUTH_SECRET variable to be defined inside the .env file. Populate the .env file with these two variables:

// ๐Ÿ‘‡ .env

# Rest of the environment variables...

# For running the application locally, set NEXTAUTH_URL to: <http://localhost:3000>
NEXTAUTH_URL=

# Set NEXTAUTH_SECRET to a random cryptographic string.
# For generating a new secret, run: `openssl rand -base64 32`
NEXTAUTH_SECRET=

Enter fullscreen mode Exit fullscreen mode

User Registration API

In the src/app/api/auth/register/route.ts, we create an endpoint for user registration that hashes the password and stores user data in the database. We then return appropriate responses based on validation success.

// ๐Ÿ‘‡ src/app/api/auth/register/route.ts

import { AuthSchema } from "@/lib/validators/auth";
import { NextRequest, NextResponse } from "next/server";
import { hash } from "bcrypt";
import { db } from "@/db";

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const validatedFields = AuthSchema.safeParse(body);

    if (!validatedFields.success) {
      return NextResponse.json(
        {
          errors: validatedFields.error.flatten().fieldErrors,
        },
        { status: 422 },
      );
    }

    const { email, password } = validatedFields.data;

    const hashedPassword = await hash(password, 12);
    const user = await db.user.create({
      data: {
        email,
        password: hashedPassword,
      },
    });

    return NextResponse.json(user);
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Something went wrong" },
      { status: 500 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Here, we parse the data received from the client and validate it with the AuthSchema we wrote earlier. Then, we create a hash with a rotation value of 12. This generates an encrypted text that we will store in our database, and finally, we return the user.

Now to make our application a lot solid, letโ€™s add a middleware that checks for the userSession anytime a user visits a certain route, and if they are not authenticated they are not allowed to visit that route.

Middleware for Route Protection

We add a middleware to restrict access to the /kanban route for unauthenticated users.

// ๐Ÿ‘‡ src/middleware.ts

export { default } from "next-auth/middleware";

export const config = { matcher: ["/kanban/:path*"] };

Enter fullscreen mode Exit fullscreen mode

Here, we are saying that a user should not be able to visit the โ€œ/kanbanโ€ route if they are not authenticated.

We are done with the backend logic for handling authentication. Letโ€™s work on some client-side logic.


Build Navbar Component

Our Navbar component is going to be comprised of some smaller components as well. We will have a button to login, register, logout and a select tag to allow the user to switch languages.

Letโ€™s begin working on these components!

LangSelector Component

Inside the src/app/components directory create a new file lang-selector.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/components/lang-selector.tsx

"use client";

import { useTolgee, useTranslate, T } from "@tolgee/react";
import { setLanguage } from "@/tolgee/language";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
} from "@/components/ui/select";
import {
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";

export const LangSelector = () => {
  const tolgee = useTolgee(["language"]);
  const locale = tolgee.getLanguage();

  const { t } = useTranslate();

  function onSelectChange(value: string) {
    setLanguage(value);
  }

  const languageOptions = [
    { code: "en", label: "English" },
    { code: "cs", label: "ฤŒesky" },
    { code: "fr", label: "Franรงais" },
    { code: "de", label: "Deutsch" },
  ];

  return (
    <Select value={locale} onValueChange={onSelectChange}>
      <SelectTrigger className="w-[200px] border rounded-md">
        <SelectValue placeholder={t("select-a-language")} />
        <ChevronDown className="ml-2 w-4 h-4 inline" />
      </SelectTrigger>

      <SelectContent>
        <SelectGroup>
          <SelectLabel className="mb-1">
            <T keyName="language" />
          </SelectLabel>
          {languageOptions.map(({ code, label }) => (
            <SelectItem key={code} value={code}>
              {label}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  );
};

Enter fullscreen mode Exit fullscreen mode

The component should be pretty self-explanatory. We are using the <Select /> component provided by shadcn/ui to map on all the available language choices we have. Based on the user selection, we set the language to that with the setLanguage function we worked on in language.ts file earlier.

๐Ÿ’ก NOTE: Notice how we are not hardcoding any text in the code; instead, we are using components from Tolgee to render the text. This way, when the user switches the language, the text changes accordingly. If we hardcoded the text, implementing translations would be ineffective. We will continue using this approach moving forward.

We are using the <T /> component and the t function we get from useTranslate hook from Tolgee to apply translations. To learn about their differences, visit here.

LogoutBtn Component

Similarly, Inside this components directory create a new file called logout-btn.tsx with the following lines of code:

// ๐Ÿ‘‡ src/components/logout-btn.tsx

"use client";

import { signOut } from "next-auth/react";
import { Button, buttonVariants } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { T, useTranslate } from "@tolgee/react";
import { useState } from "react";
import { toast } from "@/hooks/use-toast";
import { LoaderCircle } from "lucide-react";

export const LogoutBtn = () => {
  const router = useRouter();
  const { t } = useTranslate();

  const [isLoading, setIsLoading] = useState<boolean>(false);

  const handleLogout = async () => {
    setIsLoading(true);

    try {
      await signOut();
      router.push("/login");
      router.refresh();
    } catch (error) {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        variant: "destructive",
      });
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Button
      onClick={handleLogout}
      className={buttonVariants({
        className:
          "text-gray-800 text-md px-3 py-2 rounded hover:bg-blue-50 hover:text-blue-700 transition",
        variant: "secondary",
      })}
      disabled={isLoading}
    >
      {isLoading && (
        <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin mr-2" />
      )}
      <T keyName="logout" />
    </Button>
  );
};

Enter fullscreen mode Exit fullscreen mode

Similar to earlier, when the user clicks on the Button, we trigger the handleLogout function which then tries to log out the user and if any error is occured, it then shows a toast notification with the translated error message.

We use our loading state to display a loader icon when logging out the user.

Navbar Component

Finally, now that both of the smaller components that we required are available, letโ€™s work on the <Navbar /> component.

// ๐Ÿ‘‡ src/components/navbar.tsx

import { ListTodo } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { LogoutBtn } from "@/components/logout-btn";
import { buttonVariants } from "@/components/ui/button";
import { LangSelector } from "@/components/lang-selector";
import { T } from "@/tolgee/server";

export const Navbar = async () => {
  const session = await getServerSession();

  return (
    <nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 sticky top-0 z-50">
      <Link
        href={"/"}
        className="text-xl font-semibold hidden text-gray-800 sm:flex items-center select-none"
      >
        <ListTodo size={30} className="mr-2 inline" />
        <T keyName="kanban" />
      </Link>
      <div className="flex gap-4 ml-auto">
        <LangSelector />
        {session ? (
          <LogoutBtn />
        ) : (
          <>
            <Link
              href="/login"
              className={buttonVariants({
                className:
                  "text-gray-600 text-lg px-3 py-2 rounded hover:bg-blue-50 hover:text-blue-700 transition",
                variant: "outline",
              })}
            >
              <T keyName="login" />
            </Link>
            <Link
              href="/register"
              className={buttonVariants({
                className:
                  "text-gray-600 text-lg px-3 py-2 rounded hover:bg-blue-50 hover:text-blue-700 transition",
                variant: "outline",
              })}
            >
              <T keyName="register" />
            </Link>
          </>
        )}
      </div>
    </nav>
  );
};

Enter fullscreen mode Exit fullscreen mode

This Navbar component creates a navigation bar for our application. It checks if a user is logged in using getServerSession. If the user is authenticated, a logout button is shown. If not, it shows the link to the user to login and register.


Build Authentication Pages

Now, that we are done with handling the backend logic for authentication and also done implementing Tolgee to our application. Letโ€™s work on some client-side logic and build some UI.

Login Component

Inside the app/components directory, create a new file login.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/components/login.tsx

"use client";

import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { T, useTranslate } from "@tolgee/react";
import { LoaderCircle } from "lucide-react";
import { Label } from "@/components/ui/label";

export const Login = () => {
  const router = useRouter();
  const { toast } = useToast();

  const { t } = useTranslate();

  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [isLoading, setIsLoading] = useState<boolean>(false);

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setIsLoading(true);

    try {
      const response = await signIn("credentials", {
        email,
        password,
        redirect: false,
      });

      if (response?.error) {
        toast({
          title: t("something-went-wrong"),
          variant: "destructive",
        });
      } else {
        router.push("/");
        router.refresh();
      }
    } catch (error) {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        variant: "destructive",
      });
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-lg max-w-sm w-full">
        <h2 className="text-2xl font-bold mb-6 text-center text-gray-900">
          <T keyName="login" />
        </h2>

        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <Label
              htmlFor="email"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="email" />
            </Label>
            <Input
              type="email"
              name="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder={t("email")}
              required
              className="w-full p-3 border border-gray-300 rounded"
            />
          </div>

          <div className="mb-6">
            <Label
              htmlFor="password"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="password" />
            </Label>
            <Input
              type="password"
              name="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder={t("password")}
              required
              className="w-full p-3 border border-gray-300 rounded"
            />
          </div>

          <Button
            type="submit"
            className="w-full bg-gray-600 text-white p-3 rounded hover:bg-gray-700 transition duration-200"
            disabled={isLoading}
          >
            {isLoading && (
              <LoaderCircle className="w-5 h-5 text-gray-300 mr-2 animate-spin" />
            )}
            <T keyName="login" />
          </Button>

          <p className="text-center mt-4">
            <T keyName="dont-have-an-account" />{" "}
            <Link
              href="/register"
              className="text-blue-500 hover:text-blue-600 transition duration-200"
            >
              <T keyName="register" />
            </Link>
          </p>
        </form>
      </div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

This Login component displays a login form for email and password, with both input fields functioning as controlled components. Upon form submission, it calls signIn from next-auth to handle authentication. If the login fails, a translated error message is displayed via a toast notification. Successful logins redirect the user to the homepage.

We also have a separate loading state variable, which we use to show a loading animation icon while logging the user into our application.

Currently, this is just a component we have created; it is not yet displayed in our application. To do that, we need to render this component in the app directory of our application.

Login Page Route

Inside the src/app/login directory, create a new file called page.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/login/page.tsx

import { Login } from "@/components/login";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await getServerSession();
  if (session) redirect("/kanban");

  return <Login />;
}

Enter fullscreen mode Exit fullscreen mode

In the login page, we first check if the user has an active session. If the user has an active session, we simply redirect them to the โ€œ/kanbanโ€ route (which we will implement shortly). If the user does not have an active session, we display the earlier <Login /> component we built.

We have now completed the implementation of the Login page; similarly, letโ€™s build the Register page.

Register Component

Inside the app/components directory, create a new file register.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/components/register.tsx

"use client";

import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import Link from "next/link";
import { T, useTranslate } from "@tolgee/react";
import { LoaderCircle } from "lucide-react";
import { Label } from "@/components/ui/label";
import axios from "axios";
import { useMutation } from "@tanstack/react-query";

export const Register = () => {
  const router = useRouter();
  const { toast } = useToast();

  const { t } = useTranslate();

  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");

  const { mutate: register, isPending } = useMutation({
    mutationFn: async () => {
      const payload = {
        email,
        password,
      };
      await axios.post("/api/auth/register", payload);
    },
    onSuccess: () => {
      router.push("/login");
      router.refresh();
    },
    onError: (error) => {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        description: t("there-was-a-problem-registering-your-account"),
        variant: "destructive",
      });
    },
  });

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    register();
  }

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-lg max-w-sm w-full">
        <h2 className="text-2xl font-bold mb-6 text-center text-gray-900">
          <T keyName="register" />
        </h2>

        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <Label
              htmlFor="email"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="email" />
            </Label>
            <Input
              type="email"
              name="email"
              placeholder={t("email")}
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full p-3 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>

          <div className="mb-6">
            <Label
              htmlFor="password"
              className="text-xs font-bold uppercase text-gray-500"
            >
              <T keyName="password" />
            </Label>
            <Input
              type="password"
              name="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder={`${t("password")} (${t("min-length-8")})`}
              required
              className="w-full p-3 border border-gray-300 rounded"
            />
          </div>

          <Button
            type="submit"
            className="w-full bg-gray-600 text-white p-3 rounded hover:bg-gray-600 transition duration-200"
            disabled={isPending}
          >
            {isPending && (
              <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin mr-2" />
            )}
            <T keyName="register" />
          </Button>

          <p className="text-center mt-4">
            <T keyName="already-have-an-account" />{" "}
            <Link
              href="/login"
              className="text-blue-500 hover:text-blue-600 transition duration-200"
            >
              <T keyName="login" />
            </Link>
          </p>
        </form>
      </div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

The email and password inputs in this component function as controlled components, similar to those on the login page. Here, we use React Query to simplify the process of making the POST request. This approach eliminates the need to manage separate states for loading or error handling.

When the user clicks the submit button in the form, a POST request is made to our API route for registering a user in the database we worked on earlier. If the registration succeeds, the user is redirected to the login page. If not, a toast message is displayed with the translated error message.

When the user clicks the submit button, a POST request is sent to our API route to register the user in the database we previously set up. Upon successful registration, the user is redirected to the login page. If the registration fails, we display a toast message with the translated error message using the relevant keys.

Register Page Route

Inside the src/app/register directory, create a new file called page.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/register/page.tsx

import { Register } from "@/components/register";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await getServerSession();
  if (session) redirect("/kanban");

  return <Register />;
}

Enter fullscreen mode Exit fullscreen mode

With this page set up, we have completed the authentication flow of our application. You should now have a working authentication-enabled application with localization support.


Setup WebSocket and QueryClient Provider

In this section, we will set up a WebSocket server for our application. Letโ€™s first create a function that helps us get access to the socket.

getSocket Function

Inside the src/config directory, create a new file socket.ts with the following lines of code:

// ๐Ÿ‘‡ src/config/socket.ts

import { io, Socket } from "socket.io-client";

let socket: Socket;

export const getSocket = (): Socket => {
  if (socket) return socket;

  socket = io(process.env.NEXT_PUBLIC_APP_URL as string, {
    autoConnect: false,
  });

  return socket;
};

Enter fullscreen mode Exit fullscreen mode

This code defines a function getSocket that initializes a Socket.IO client connection to the URL specified in the environment variable NEXT_PUBLIC_APP_URL, ensuring that the socket is created only once. If the socket is already initialized, it simply returns the existing socket instance.

Socket Provider

Now, we need to manage our socket.io connection and provide a way for our components to access the socket instance. Inside the src/providers directory, create a new file socket-provider.tsx with the following lines of code:

// ๐Ÿ‘‡ src/providers/socket-provider.tsx

"use client";

import { createContext, ReactNode, useContext, useMemo } from "react";
import { getSocket } from "@/config/socket";
import type { Socket } from "socket.io-client";

interface SocketContextType {
  socket: Socket | null;
}

const SocketContext = createContext<SocketContextType | undefined>(undefined);

export const useSocket = () => {
  const context = useContext(SocketContext);
  if (!context) {
    throw new Error("'useSocket' must be used within a 'SocketProviderClient'");
  }
  return context.socket;
};

export default function SocketProviderClient({
  children,
}: {
  children: ReactNode;
}) {
  const socket = useMemo(() => {
    const socketInstance = getSocket();
    return socketInstance.connect();
  }, []);

  return (
    <SocketContext.Provider value={{ socket }}>
      {children}
    </SocketContext.Provider>
  );
}

Enter fullscreen mode Exit fullscreen mode

This code creates a React context for managing a Socket.IO connection, providing a useSocket hook to access the socket instance. The SocketProviderClient initializes the socket using the getSocket function and connects it, then wraps its children in a context provider to share the socket instance throughout the application.

Now, we need to wrap our application with this socket provider to get access to using WebSocket for sending and receiving data.

QueryClient and SocketProvider

Inside the same directory, create a new file providers.tsx which we will use to wrap our child components with the QueryClientProvider from @tanstack/react-query and our newly created SocketProviderClient.

Add the following lines of code to the file:

// ๐Ÿ‘‡ src/providers/providers.tsx

"use client";

import { PropsWithChildren, useState } from "react";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SocketProviderClient from "@/providers/socket-provider";

const Providers = ({ children }: PropsWithChildren) => {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <SocketProviderClient>{children}</SocketProviderClient>
    </QueryClientProvider>
  );
};

export default Providers;

Enter fullscreen mode Exit fullscreen mode

Now, all we need to do is, wrap our application with this <Providers /> component which will give access to our application socket and the react-query support.

Wrap the Application Layout with the Providers

Modify the layout.tsx in the root of the project with the following lines of code:

// ๐Ÿ‘‡ src/app/layout.tsx

import type { Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
import { Navbar } from "@/components/navbar";
import Providers from "@/providers/providers";
import { getLanguage } from "@/tolgee/language";
import { getStaticData } from "@/tolgee/shared";
import { TolgeeProviderClient } from "@/tolgee/client";

// Rest of the code...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const locale = await getLanguage();

  const staticData = await getStaticData([locale, "en"]);
  return (
    <html lang={locale}>
      <Providers>
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          <TolgeeProviderClient language={locale} staticData={staticData}>
            <Navbar />
            {children}
            <Toaster />
          </TolgeeProviderClient>
        </body>
      </Providers>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

Custom WebServer with Socket.io

Now, we are ready to create our own Socket.io server. Create a new file server.ts and add the following lines of code:

// ๐Ÿ‘‡ server.ts

// NOTE: Always Keep this 'tsconfig-paths' import at the top.
// It allows us to use custom paths and aliases defined in the
// `tsconfig.json` file like '@/db'
import "tsconfig-paths/register";

import { createServer } from "node:http";
import next from "next";
import { Server } from "socket.io";
import { db } from "@/db";
import { Task as TTask } from "@prisma/client";
import { DraggableLocation } from "react-beautiful-dnd";

const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOST || "localhost";
const port = Number(process.env.PORT) || 3000;

const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);

  const io = new Server(httpServer);

  io.on("connection", (socket) => {
    console.log(`'${socket.id}' user just connected! โœจ`);

    socket.on("disconnect", () => {
      console.log(`'${socket.id}' user just disconnected! ๐Ÿ‘€`);
    });
  });

  httpServer
    .once("error", (err) => {
      console.error("ERROR: server failure", err);
      process.exit(1);
    })

    .listen(port, () => {
      console.log(`Listening on '<http://$>{hostname}:${port}'`);
    });
});

Enter fullscreen mode Exit fullscreen mode

Now, this server.ts file becomes the entry point to our application. We can do almost anything we would do with a socket.io server with a backend framework like express.js.

We can now listen for any events similar to listening for โ€˜connectionโ€™ and โ€˜disconnectโ€™ here. We will modify this file in the future to listen to our custom events.

Typescript Server Configuration

Now, create a new file tsconfig.server.json which will hold settings specific to our server. Add the following lines of code:

// ๐Ÿ‘‡ tsconfig.server.json

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "module": "CommonJS",
      "outDir": "dist",
      "lib": ["es2019"],
      "target": "es2019",
      "isolatedModules": false,
      "noEmit": false,
    },
    "include": ["server.ts"]
  }

Enter fullscreen mode Exit fullscreen mode

This tsconfig.server.json file extends the base TypeScript configuration found in tsconfig.json and specifies some custom settings for our project. It uses CommonJS for module output, and directs compiled files to the dist directory. The isolatedModules option is set to false, permitting files that may not be self-contained, while noEmit is false, allowing output files to be generated. Finally, it includes only the server.ts file in the compilation process.

Update the package.json

For our development server, we will be using nodemon and also now we are using server.ts file as our server. So, modify the scripts in the package.json file to this:

  // ๐Ÿ‘‡ package.json

  "scripts": {
    "dev": "nodemon",
    "build": "next build && tsc --project tsconfig.server.json",
    "start": "NODE_ENV=production node server.ts",
    "lint": "next lint"
  },
  // Rest of the configuration...

Enter fullscreen mode Exit fullscreen mode

Also, we need to tweak the nodemon configuration to watch changes in the server.ts file and change its execution command.

Nodemon Configuration

Create a new file nodemon.json in the root of the project with the following configuration:

// ๐Ÿ‘‡ nodemon.json

{
  "watch": ["server.ts"],
  "exec": "ts-node --project tsconfig.server.json server.ts",
  "ext": "js ts"
}

Enter fullscreen mode Exit fullscreen mode

Setup Kanban Board

Finally, now we are done with all the pre-works for our board. Letโ€™s work on displaying and creating tasks for our board.

Task Component

Inside the src/components directory, create a new file task.tsx with the following lines of code:

// ๐Ÿ‘‡ src/components/task.tsx

import { Task as TTask } from "@prisma/client";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { T } from "@tolgee/react";

import { format } from "date-fns";

export const Task = ({ task }: { task: TTask }) => {
  const createdDate = format(new Date(task.createdAt), "hh:mm a, dd MMM yyyy");

  return (
    <Card className="w-full max-w-sm my-2 mx-auto">
      <CardHeader>
        <CardTitle>{task.title}</CardTitle>
      </CardHeader>
      {task.description ? (
        <CardContent>
          <Link
            href={`/kanban/${task.id}`}
            className="text-gray-800 font-semibold underline hover:text-gray-900 underline-offset-2"
          >
            <T keyName="view-description" />
          </Link>
        </CardContent>
      ) : null}
      <CardFooter className="text-sm text-gray-500">
        <span className="font-semibold mr-2">
          <T keyName="created-on" />
          {": "}
        </span>
        {createdDate}
      </CardFooter>
    </Card>
  );
};

Enter fullscreen mode Exit fullscreen mode

We will use this to display tasks in our application. Here, we are essentially accepting a task object as a prop and using the Card component to present the task content in a card-like fashion. We are using the date-fns package to format the date in a more readable manner.

AddTask Component

Now, letโ€™s create a component that we can use to add tasks to our board. Inside the src/components directory, create a new file add-task.tsx with the following lines of code:

// ๐Ÿ‘‡ src/components/add-task.tsx

"use client";

import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { GearIcon } from "@radix-ui/react-icons";
import { useMutation } from "@tanstack/react-query";
import { useTranslate, T } from "@tolgee/react";

import { useChat } from "ai/react";
import axios from "axios";
import { useToast } from "@/hooks/use-toast";
import { useSocket } from "@/providers/socket-provider";
import { Task as TTask } from "@prisma/client";
import { TCreateTaskSchema } from "@/lib/validators/create-task";
import { LoaderCircle } from "lucide-react";

export const AddTask = ({ userId }: { userId: string }) => {
  const [title, setTitle] = useState<string>("");
  const [description, setDescription] = useState<string>("");

  const socket = useSocket();

  const { t } = useTranslate();
  const { toast } = useToast();

  const {
    messages,
    handleSubmit: handleAISubmit,
    setInput: setAIInput,
    isLoading: isAILoading,
  } = useChat();

  useEffect(() => {
    const lastAssistantMessage = messages.findLast(
      (message) => message.role === "assistant",
    )?.content;
    if (lastAssistantMessage && description !== lastAssistantMessage) {
      setDescription(lastAssistantMessage);
    }
  }, [messages, description]);

  const handleGenerateClick = () => {
    setAIInput(title);
    handleAISubmit();
  };

  const { mutate: createTask, isPending } = useMutation({
    mutationFn: async () => {
      const payload: TCreateTaskSchema = {
        title,
        description,
      };
      const { data } = await axios.post(`/api/tasks/${userId}/create`, payload);
      return data as TTask;
    },
    onSuccess: (newTask) => {
      setTitle("");
      setDescription("");

      socket?.emit("task-created", newTask);
    },
    onError: (error) => {
      console.error("ERROR:", error);
      toast({
        title: t("something-went-wrong"),
        description: t("failed-to-create-task"),
        variant: "destructive",
      });
    },
  });

  const isSubmitDisabled = isPending || title.length === 0 || isAILoading;

  const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    createTask();
  };

  const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setTitle(event.target.value);
  };

  return (
    <div className="flex justify-center mt-2">
      <div className="w-full max-w-5xl p-6 bg-white rounded-lg shadow-lg transition-shadow duration-300 ease-in-out hover:shadow-2xl">
        <form onSubmit={handleFormSubmit} className="space-y-4">
          <Input
            autoFocus
            type="text"
            placeholder={t("task-title")}
            value={title}
            onChange={handleTitleChange}
            className="w-full px-4 py-2 rounded"
          />
          <Button
            type="button"
            onClick={handleGenerateClick}
            className="flex items-center gap-2 font-semibold h-10 px-4 text-white rounded w-full sm:w-auto"
            disabled={title.split(" ").length < 3 || isPending || isAILoading}
          >
            {isAILoading ? (
              <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin" />
            ) : (
              <GearIcon className="w-5 h-5 text-gray-300" />
            )}
            <T keyName="generate" />
          </Button>

          <Textarea
            placeholder={t("task-description")}
            value={description}
            // Prevent user input in Textarea
            readOnly
            className="mt-4 w-full h-28 px-4 py-2 border border-gray-300 rounded resize-none"
          />

          <Button
            type="submit"
            className="font-semibold h-10 px-4 text-white rounded w-full sm:w-auto"
            disabled={isSubmitDisabled}
          >
            {isPending && (
              <LoaderCircle className="w-5 h-5 text-gray-300 animate-spin" />
            )}
            <T keyName="submit" />
          </Button>
        </form>
      </div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

This component has a lot going on. There are two input fields, both of which are controlled components. However, the textarea is set to readOnly since itโ€™s meant to be populated by the AI rather than by the user. We use two state variables, title, and description, to manage the title and description fields.

When the user clicks the submit button, an API request is made to our task creation endpoint, which adds a new task for the user in the database and returns it. If any errors occur, a toast displays the translated error message. Upon success, we reset the input fields and emit an event that the server will pick up, triggering an update on the board component to display all tasks.

The useChat hook, accessed from Vercel's AI SDK, is particularly interesting here. It provides access to fields like the message history and the current input message, along with the isPending variable, which tracks whether the AIโ€™s response is still loading.

When the user clicks the Generate button, we submit the title to the AI. Once we receive a response, we check the messages field using the useEffect hook. If the assistantโ€™s message updates, we set the description to this new message.

Update the server.ts file

Now, we will update the server.ts file to also listen for the task-created event. Modify the server.ts file in the root of the project with the following lines of code:

// ๐Ÿ‘‡ server.ts

// Rest of the code...

app.prepare().then(() => {
  const httpServer = createServer(handler);

  const io = new Server(httpServer);

  io.on("connection", (socket) => {
    console.log(`'${socket.id}' user just connected! โœจ`);

    socket.on("task-created", async (payload: TTask) => {
      io.sockets.emit("task-created", payload);
    });

    socket.on("disconnect", () => {
      console.log(`'${socket.id}' user just disconnected! ๐Ÿ‘€`);
    });
  });

  httpServer
    .once("error", (err) => {
      console.error("ERROR: server failure", err);
      process.exit(1);
    })

    .listen(port, () => {
      console.log(`Listening on '<http://$>{hostname}:${port}'`);
    });
});

Enter fullscreen mode Exit fullscreen mode

Here, we listen for that event, and once it is received, we emit it to all the connected sockets. It is then received by the <Board /> component, which we will create in a moment. This component will be responsible for displaying all the tasks in a board format and updating the tasks with the received data.


Setup API Routes for AI and Task creation

Now, in our <AddTask /> component, when the user clicks on the Generate button, the handleAISubmit function makes a call to /api/chat endpoint with a POST request. So, we need to create that API route for handling response streaming to our description field.

Zod Schema for Message Validation

Letโ€™s create a schema file for validation of the input from the user and the AI. Inside the src/lib/validators directory, create a new file message.ts with the following lines of code:

// ๐Ÿ‘‡ src/lib/validators/message.ts

import { z } from "zod";

const MessageSchema = z.object({
  role: z.string().min(1),
  content: z.string().min(1),
});

export const ResponseBodySchema = z.object({
  messages: z.array(MessageSchema),
});

export type TResponseBodySchema = z.infer<typeof ResponseBodySchema>;

Enter fullscreen mode Exit fullscreen mode

Now, we can use these schemas to infer the type of response from the AI to get type validation in our API route.

Chat Route for OpenAI

Finally, Inside the src/api/chat directory, create a new file route.ts with the following lines of code:

// ๐Ÿ‘‡ src/api/chat/route.ts

import { ResponseBodySchema } from "@/lib/validators/message";
import { NextRequest, NextResponse } from "next/server";

import { openai } from "@ai-sdk/openai";
import { streamText, convertToCoreMessages } from "ai";

// Allow streaming responses up to 15 seconds
export const maxDuration = 15;

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const validatedFields = ResponseBodySchema.safeParse(body);

    if (!validatedFields.success) {
      return NextResponse.json(
        {
          errors: validatedFields.error.flatten().fieldErrors,
        },
        { status: 422 },
      );
    }

    const { messages } = validatedFields.data;

    const lastUserMessage = messages.findLast(
      (message) => message.role === "user",
    )?.content;

    if (!lastUserMessage) {
      return NextResponse.json(
        { error: "No user message found" },
        { status: 400 },
      );
    }

    const response = await streamText({
      model: openai("gpt-3.5-turbo"),
      messages: convertToCoreMessages([
        {
          role: "user",
          content: `Generate a short description for a kanban board task with the title: ${lastUserMessage}.
          Make sure to give the response in plain text and not include any markdown characters.`,
        },
      ]),
    });

    return response.toDataStreamResponse();
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Something went wrong" },
      { status: 500 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

In this API route, we start by validating the input to ensure it includes a messages array where each object has a role and content field. Next, we extract the latest user message (i.e., the most recent question or request to the AI) from this array. With this message in hand, we pass it to the streamText function, prompting the AI to generate a task description based on the message content.

Finally, we return the response as a data stream, allowing the client to update the messages array in real-time. This streaming response triggers the useEffect hook, which updates the description field, displaying the AI-generated description directly in the text area.

Zod Schema for Add Task Validation

Inside the src/lib/validators directory, create a new file create-task.ts with the following lines of code:

// ๐Ÿ‘‡ src/lib/validators/create-task.ts

import { z } from "zod";

export const CreateTaskSchema = z.object({
  title: z.string().trim().min(1).max(50),
  description: z.string().trim().optional(),
});

export type TCreateTaskSchema = z.infer<typeof CreateTaskSchema>;

Enter fullscreen mode Exit fullscreen mode

The CreateTaskSchema schema defines the structure for creating a task. It requires a title between 1 and 50 characters and includes an optional description.

The inferred type, TCreateTaskSchema, provides type safety for this structure, allowing us to use it for consistent typing in both client-side and server-side code.

API Endpoint for Creating a Task

Now, letโ€™s work on the task creation endpoint, i.e. /api/tasks/[userId]/create.

Create a new directory with this path and create a route.ts inside the file with the following lines of code:

// ๐Ÿ‘‡ app/api/tasks/[userId]/create/route.ts

import { db } from "@/db";
import { CreateTaskSchema } from "@/lib/validators/create-task";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

export async function POST(
  req: NextRequest,
  { params }: { params: { userId: string } },
) {
  try {
    const session = await getServerSession();

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const body = await req.json();

    const validatedFields = CreateTaskSchema.safeParse(body);
    if (!validatedFields.success) {
      return NextResponse.json(
        { error: validatedFields.error.flatten().fieldErrors },
        { status: 422 },
      );
    }

    const { title, description } = validatedFields.data;

    const columnTasks = await db.task.findMany({
      where: {
        userId: params.userId,
        column: 0,
      },
    });

    const newOrder = columnTasks.length;

    const newTask = await db.task.create({
      data: {
        title,
        ...(description ? { description } : {}),
        userId: params.userId,
        column: 0,
        order: newOrder,
      },
    });

    return NextResponse.json(newTask);
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

This API route creates a new task. It first checks for a valid user session with getServerSession. If there is no active session (the user is not logged in), it returns a 401 Unauthorized error. Next, it validates the request body with CreateTaskSchema, and if validation fails, it responds with a 422 status and error details.

If the input is valid, it counts tasks in the default column (0 - ongoing) for ordering, then creates a new task in the database with the provided title, optional description, user ID, column, and order value, which is the length of the array. The new task is returned on success; otherwise, it returns an Internal Server Error.


Build the Kanban Board

๐Ÿ’ก Here, we will build the main UI components and some APIs for updating tasks on our Board

Board Component

Now, letโ€™s create a <Board /> component that renders multiple different tasks for our application.

Inside the src/components directory, create a new file board.tsx with the following lines of code:

// ๐Ÿ‘‡ src/components/board.tsx

"use client";

import { useSocket } from "@/providers/socket-provider";
import { useEffect, useState } from "react";
import {
  DragDropContext,
  Draggable,
  Droppable,
  DropResult,
} from "react-beautiful-dnd";
import { getSession } from "next-auth/react";
import axios from "axios";
import { Session } from "next-auth";
import { Task as TTask } from "@prisma/client";
import { Task } from "@/components/task";
import { T, useTranslate } from "@tolgee/react";
import { useToast } from "@/hooks/use-toast";

export const Board = ({ userId }: { userId: string }) => {
  const socket = useSocket();
  const { toast } = useToast();

  const { t } = useTranslate();

  const [tasks, setTasks] = useState<TTask[] | null>([]);
  const [session, setSession] = useState<Session | null>(null);

  useEffect(() => {
    const fetchSession = async () => {
      try {
        const sessionData = await getSession();
        setSession(sessionData);
      } catch (error) {
        console.error("ERROR:", error);
        toast({
          title: t("something-went-wrong"),
          variant: "destructive",
        });
      }
    };
    fetchSession();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    if (!session) return;

    const fetchUserTasks = async () => {
      try {
        const userEmail = session.user?.email || "";
        const { data } = (await axios.get("/api/tasks", {
          params: { userId, email: userEmail },
        })) as { data: { tasks: TTask[] } };

        setTasks(data.tasks);
      } catch (error) {
        console.error("ERROR:", error);
      }
    };
    fetchUserTasks();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [session]);

  useEffect(() => {
    const handleTasksUpdated = (data: TTask[] | undefined) => {
      if (!data) return;
      setTasks(data);
    };

    const handleTaskCreated = (newTask: TTask) => {
      setTasks((prevTasks) => [...(prevTasks || []), newTask]);
    };

    socket?.on("tasks-updated", handleTasksUpdated);
    socket?.on("task-created", handleTaskCreated);

    return () => {
      socket?.off("tasks-updated", handleTasksUpdated);
      socket?.off("task-created", handleTaskCreated);
    };
  }, [socket]);

  const tasksByStatus = (status: number) =>
    tasks?.filter((task) => task.column === status);

  const columns = {
    0: "Ongoing",
    1: "Pending",
    2: "Completed",
  };

  const handleDragEnd = ({ destination, source }: DropResult) => {
    if (!destination) return;
    if (
      destination.index === source.index &&
      destination.droppableId === source.droppableId
    )
      return;

    socket?.emit("task-drag", {
      source,
      destination,
      email: session?.user?.email || "",
    });
  };

  if (!tasks || tasks.length === 0) return null;

  return (
    <div className="container mx-auto mt-10 mb-5">
      <DragDropContext onDragEnd={handleDragEnd}>
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
          {Object.entries(columns).map(([status, title]) => (
            <div
              key={status}
              className="p-4 border rounded-lg shadow-lg bg-gray-50 flex flex-col items-center"
            >
              <h2 className="text-xl font-bold mb-4 text-center">
                <T keyName={title.toLowerCase()} />
              </h2>
              <Droppable droppableId={status}>
                {(provided) => (
                  <div
                    {...provided.droppableProps}
                    ref={provided.innerRef}
                    className="w-full flex flex-col items-center min-h-40"
                  >
                    {tasksByStatus(Number(status))?.map((task, index) => (
                      <Draggable
                        key={task.id}
                        draggableId={task.id}
                        index={index}
                      >
                        {(provided) => (
                          <div
                            ref={provided.innerRef}
                            {...provided.draggableProps}
                            {...provided.dragHandleProps}
                            className="w-full"
                          >
                            <Task task={task} />
                          </div>
                        )}
                      </Draggable>
                    ))}
                    {provided.placeholder}
                  </div>
                )}
              </Droppable>
            </div>
          ))}
        </div>
      </DragDropContext>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

This is the component where we will be utilizing the main feature of a Kanban board, i.e., drag-and-drop items. For this, we will use our previously installed package, react-beautiful-dnd. The component first fetches the user session using getSession and sets it in the state. Once the session is available, it makes an API call to fetch tasks for the logged-in user and stores them in tasks.

It listens for two-socket events โ€”tasks-updated, which updates the task list, and task-created, which appends a new task to the current task list.

Tasks are grouped by column status (0 for "Ongoing," 1 for "Pending," and 2 for "Completed") using the tasksByStatus function. The component maps these statuses to render each column with the corresponding tasks.

The DragDropContext wrapper enables drag-and-drop functionality. When a task is moved, handleDragEnd sends the new task order to the server via a socket event for syncing.

Each column is a Droppable area that contains draggable Task components, allowing users to reorder tasks within and across columns.

API Route to fetch User Tasks

Now, letโ€™s work on the /api/tasks route which is responsible for returning a list of user tasks from the database.

Inside the app/api/tasks, create a route.ts file with the following lines of code:

// ๐Ÿ‘‡ src/app/api/tasks/routs.ts

import { db } from "@/db";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession();

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const url = new URL(req.url);
    const passedEmail = url.searchParams.get("email") || "";
    const userId = url.searchParams.get("userId");

    if (!userId || session.user?.email !== passedEmail) {
      return NextResponse.json({ error: "Forbidden" }, { status: 403 });
    }

    const userWithBoardsAndTasks = await db.user.findUnique({
      where: { email: passedEmail, id: userId },
      select: {
        id: true,
        password: false,
        tasks: true,
      },
    });

    if (!userWithBoardsAndTasks) {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }

    return NextResponse.json(userWithBoardsAndTasks);
  } catch (error) {
    console.error("ERROR:", error);
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

The GET function in this API route fetches a user's information, including their tasks. It starts by validating the authentication using getServerSession. If the session is absent, a 401 Unauthorized status is returned.

The route extracts the email and userId from the request URL's query parameters. If the userId is missing or the session's user email does not match the provided email, a 403 Forbidden status is returned.

Next, it queries the database for a user with the specified email and ID, selecting only the user's ID and tasks. If no user is found, a 404 Not Found status is returned. If the user exists, their data is sent in the response.

Now, we are almost done; we just need to listen for the task-drag event from the <Board /> component inside the server.ts file and handle it accordingly.

Update the server.ts file

Modify the server.ts file in the root of the project with the following lines of code:

// ๐Ÿ‘‡ server.ts

// NOTE: Always Keep this 'tsconfig-paths' import at the top.
// It allows us to use custom paths and aliases defined in the
// `tsconfig.json` file like '@/db'
import "tsconfig-paths/register";

import { createServer } from "node:http";
import next from "next";
import { Server } from "socket.io";
import { db } from "@/db";
import { Task as TTask } from "@prisma/client";
import { DraggableLocation } from "react-beautiful-dnd";

const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOST || "localhost";
const port = Number(process.env.PORT) || 3000;

const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);

  const io = new Server(httpServer);

  io.on("connection", (socket) => {
    console.log(`'${socket.id}' user just connected! โœจ`);

    socket.on("task-created", async (payload: TTask) => {
      io.sockets.emit("task-created", payload);
    });

    socket.on(
      "task-drag",
      async (payload: {
        source: DraggableLocation;
        destination: DraggableLocation;
        email: string;
      }) => {
        const { source, destination, email } = payload;

        try {
          const updatedTasks = await handleTaskDrag(email, source, destination);
          if (updatedTasks) {
            io.sockets.emit("tasks-updated", updatedTasks);
          }
        } catch (error) {
          console.error(
            "ERROR: failed to update or fetch the user tasks",
            error,
          );
        }
      },
    );

    socket.on("disconnect", () => {
      console.log(`'${socket.id}' user just disconnected! ๐Ÿ‘€`);
    });
  });

  httpServer
    .once("error", (err) => {
      console.error("ERROR: server failure", err);
      process.exit(1);
    })

    .listen(port, () => {
      console.log(`Listening on '<http://$>{hostname}:${port}'`);
    });
});

async function handleTaskDrag(
  email: string,
  source: DraggableLocation,
  destination: DraggableLocation,
) {
  const dbUser = await db.user.findUnique({ where: { email } });
  if (!dbUser) return;

  const tasks = await db.task.findMany({ where: { userId: dbUser.id } });
  return updateTasksInDB(tasks, source, destination);
}

async function updateTasksInDB(
  tasks: TTask[],
  source: DraggableLocation,
  destination: DraggableLocation,
) {
  const { droppableId: sourceColumn, index: sourceOrder } = source;
  const { droppableId: destinationColumn, index: destinationOrder } =
    destination;

  const taskMoved = tasks.find(
    (task) =>
      task.column === Number(sourceColumn) && task.order === sourceOrder,
  );

  if (!taskMoved) return;

  // Filter the moved task from the tasks array.
  tasks = tasks.filter((task) => task.id !== taskMoved.id);

  taskMoved.column = Number(destinationColumn);
  taskMoved.order = destinationOrder;

  const columns: { [key: number]: TTask[] } = {
    0: tasks.filter((task) => task.column === 0),
    1: tasks.filter((task) => task.column === 1),
    2: tasks.filter((task) => task.column === 2),
  };

  columns[taskMoved.column].splice(destinationOrder, 0, taskMoved);

  // Reorder each column to have sequential order values
  Object.values(columns).forEach((columnTasks) => {
    columnTasks.forEach((task, index) => {
      task.order = index;
    });
  });

  tasks = [...columns[0], ...columns[1], ...columns[2]];

  // Sort tasks by column and order before returning
  tasks.sort((a, b) =>
    a.column === b.column ? a.order - b.order : a.column - b.column,
  );

  const updateTasksPromises = tasks.map((task) =>
    db.task.update({
      where: {
        id: task.id,
      },
      data: {
        column: task.column,
        order: task.order,
      },
    }),
  );

  // Execute all updates in parallel
  await Promise.all(updateTasksPromises);

  return tasks;
}

Enter fullscreen mode Exit fullscreen mode

The task-drag event is responsible for handling the drag-and-drop functionality of tasks within your Kanban board. When a task is dragged from one position to another, this event is triggered, allowing the server to update the task's status and position in the database.

When a client emits the 'task-drag' event, it sends a payload containing the source and destination locations of the task being dragged, as well as the user's email address. The server listens for this event.

The server then calls the handleTaskDrag function, passing the user's email, the source, and the destination as arguments. This function is responsible for fetching the user from the database using their email address, ensuring that the task updates are associated with the correct user.

Within handleTaskDrag, the function retrieves the user's tasks from the database and then calls updateTasksInDB, which processes the task update logic. This function updates the column and order of the task based on the drag-and-drop operation, ensuring that the tasks are rearranged correctly in the database.

If the tasks are updated successfully, the updated tasks are emitted back to all connected clients using io.sockets.emit, broadcasting the changes so that the user interface can be updated in real time.

Now that we have both the <AddTask /> and the <Board /> components ready, it's time to use them inside our application.

Kanban Page Route

Inside the src/app/kanban directory, create a new file page.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/kanban/page.tsx

import { AddTask } from "@/components/add-task";
import { Board } from "@/components/board";
import { db } from "@/db";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await getServerSession();
  if (!session) redirect("/login");

  const dbUser = await db.user.findUnique({
    where: { email: session.user?.email || "" },
    select: { id: true },
  });

  if (!dbUser) redirect("/register");

  const userId = dbUser.id;

  return (
    <>
      <AddTask userId={userId} />
      <Board userId={userId} />
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

It starts by checking the user's session with getServerSession, redirecting to the login page if the session is absent. This statement is probably never going to execute because we built a middleware.ts file earlier in the src directory, which states that any route starting with /kanban cannot be accessed by unauthenticated users.

However, it never hurts to add an extra layer of validation, as Next.js deduplicates any similar duplicate requests. After confirming the session, it retrieves the user's ID from the database; if the user is not found, it redirects to the registration page.

Finally, it renders the AddTask and Board components, passing the user's ID as a prop.

There is one last thing remaining: if youโ€™ve noticed, in the <Task /> component earlier, we had a way for the user to view the description with a link to /kanban/[taskId].

Kanban Description Page Route

Inside the src/app/kanban/[taskId] directory, create a new file page.tsx with the following lines of code:

// ๐Ÿ‘‡ src/app/kanban/[taskId]/page.tsx

import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db } from "@/db";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslate, T } from "@/tolgee/server";
import { getServerSession } from "next-auth";

export default async function Page({ params }: { params: { taskId: string } }) {
  const session = await getServerSession();
  if (!session) redirect("/login");

  const task = await db.task.findUnique({
    where: { id: params.taskId },
  });

  if (!task) redirect("/kanban");

  const t = await getTranslate();

  return (
    <div className="flex items-center justify-center px-4 mt-20">
      <div className="w-full max-w-lg space-y-4">
        <Link
          href={"/kanban"}
          className={buttonVariants({
            variant: "secondary",
            size: "lg",
          })}
        >
          <ArrowLeft className="mr-2 h-4 w-4" />
          <T keyName="back" />
        </Link>

        <Card className="p-6 bg-white shadow-lg rounded-lg border border-gray-200">
          <CardHeader>
            <CardTitle className="text-2xl font-bold text-gray-800">
              {task.title}
            </CardTitle>
          </CardHeader>

          <CardContent>
            <p className="text-gray-700 text-lg leading-relaxed">
              {task.description || t("no-description-provided-for-this-task")}
            </p>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

The same is true here: we first validate the session. As mentioned earlier, this should never be executed because of the middleware we already have in place.

Then, we simply fetch the task from the database using the taskId we received as a prop. If the task does not exist, we redirect the user to the /kanban page. If it does exist, we display the title and description of the task.

Home Page Route

Finally, letโ€™s work on the root Home page of our application (/ route). Modify the src/app/page.tsx with the following lines of code:

import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";

export default async function Home() {
  const session = await getServerSession();
  if (session) redirect("/kanban");

  redirect("/login");
}

Enter fullscreen mode Exit fullscreen mode

Here, we simply check if the user is authenticated. If they are, they are sent to the /kanban route; if not, they are redirected to the login page.

That is literally all we need to do to get our Kanban board running perfectly. Now, you should have a fully functioning Kanban board with authentication, localization, and real-time support. ๐Ÿฅณ

ScreeenShot


Conclusion โšก

Wow! ๐Ÿ˜ฎโ€๐Ÿ’จ Weโ€™ve accomplished a lot together today.

If you made it this far, youโ€™ve successfully built an AI and localization-powered Kanban board from scratch along with the help of a blog post. Give yourself a well-deserved pat on the back!

Star the Tolgee repository โญ

Follow Tolgee for more content like this.

Share your thoughts in the comment section below! ๐Ÿ‘‡

Thank you so much for reading! ๐ŸŽ‰ ๐Ÿซก

Thank You GIF


Top comments (1)

Collapse
 
quantumowl profile image
Mata Quanti

Nice tutorial, thanks for sharing. Love the AI support for the tasks