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? ๐ฅ
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
Next, navigate into the newly created Next.js project:
cd kanban-ai-realtime-localization
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
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
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
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
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
}
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
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"
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
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"
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;
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
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
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:
- 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;
}
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
.
- 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",
});
}
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.
- 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=
- 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 } }),
}),
});
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.
- 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>
);
};
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.
- Wrap the Application with
TolgeeProviderClient
inlayout.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>
);
}
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>;
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 };
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=
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 },
);
}
}
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*"] };
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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 />;
}
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>
);
};
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 />;
}
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;
};
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>
);
}
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;
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>
);
}
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}'`);
});
});
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"]
}
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...
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"
}
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>
);
};
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>
);
};
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}'`);
});
});
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>;
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 },
);
}
}
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>;
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 },
);
}
}
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>
);
};
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 },
);
}
}
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;
}
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} />
</>
);
}
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>
);
}
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");
}
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. ๐ฅณ
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! ๐ ๐ซก
Top comments (1)
Nice tutorial, thanks for sharing. Love the AI support for the tasks