Next.js

Demo Repo

Adam Legner, Martin Kopecký

KI/GUI

nextlogonextlogonextlogo

1. Prequisites

To create this project for KI/GUI we need to install NodeJS, Docker, Git and text editor of your choice (we recommend using VSCode)

As for data storage we are gonna use redis as docker container. So run the following command in terminal

docker run --name redis -d -p 6379:6379 redis

2. Installation

To initialize any next project we need to run this command

npx create-next-app@latest

After the first npx command, this prompt might appear, if it does please just press 'Y'

Need to install the following packages:
create-next-app@14.1.4
Ok to proceed? (y) Y

Now we need to configure next for this project

What is your project named? Project-name
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No /Yes
Would you like to use App Router? (recommended) No /Yes
Would you like to customize the default import alias (@/*)?No / Yes

Now we need to install 2 dependencies for this project

npm install --save ioredis geist

It is good practice to save any secret into .env file. We will follow that rule and we will create .env at root of our project with following code

NEXT_PUBLIC_BASE_URL="http://localhost:3000"
REDIS_URL="redis://default:@localhost:6379"

Because .env file holds secrets, it is important to add it to .gitignore.

.env

Now to start development server by running this command in project folder.

npm run dev

After this node server will start on http://localhost:3000

3. Basic Configuration

As a first thing we will change the font. To change it we need to modifie src/app/layout.tsx

by changing this line

import {Inter} from 'next/font/google';
to import GeistSans font
import {GeistSans} from 'geist/font/sans';

then removing this line

const inter = Inter({subsets: ['latin']}); 

and at last changing body tag className from Inter

<body className={Inter.className}>{children}</body>

to GeistSans to apply font to whole document

<body className={GeistSans.className}>{children}</body>

So the src/app/layout.tsx should look like this

import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import "./globals.css";


export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
    <body className={GeistSans.className}>{children}</body>
    </html>
    );
  }

To use ioredis we need to configure it. To do so we're going to create src/lib folder in which we'll create redis.ts file.

import Redis from "ioredis";

export default class RedisLib {
  private static instance: RedisLib;
  private redisClient: Redis;

  private constructor() {
    this.redisClient = new Redis(process.env.REDIS_URL as string);
  }

  public static getInstance(): RedisLib {
    if (!this.instance) {
      if (!process.env.REDIS_URL) {
        throw new Error("REDIS_URL is not defined");
      }
      this.instance = new RedisLib();
    }
    return this.instance;
  }

  public getClient(): Redis {
    return this.redisClient;
  }
}

As last thing for basic configuration we're going to create Error path that is specific to Next.js. To create it we need to add file called src/app/error.tsx in which we'll create Error component

'use client'
import { useEffect } from 'react'

export default function Error({
 error,
}: {
 error: Error & { digest?: string }
 reset: () => void
}) {
 useEffect(() => {
  console.error(error)
 }, [error])

 return (
  <div className="w-full h-[90vh] flex flex-col justify-center items-center gap-10">
   <h2 className="text-3xl font-bold">Something went wrong!</h2>
   <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-500" onClick={() => window.location.reload()}>
    Try again
   </button>
   <button className="text-xl bg-red-400 rounded-xl px-4 py-2 text-black hover:bg-red-500" onClick={() => window.history.back()}>
    Go Back
   </button>
   <div className="flex flex-col justify-center items-center gap-1">
    <code>Name: {error.name}</code>
    <code>Message: {error.message}</code>
   </div>
  </div>
 )
}
        

4. Home page

Now we're going to delete everything from /app/page.tsx and paste in following code

export default function Home() {
return(<h1>Hello World</h1>)
}

This should turn the next template into a Hello World message .... Good job, you're smart cookie

Ok, let's actually start making the real app. To do so we're gonna delete <h1>Hello World</h1> and create simple div with header, input field and button. Code should be something like this.

<main className="w-full h-[95vh] flex justify-center items-center">
<div className=" flex flex-col items-center w-1/4 h-fit outline-1 outline outline-gray-500 bg-gray-900 rounded-lg gap-10 py-5 px-3">
  <h1 className="text-3xl text-white font-bold" > ShortURL </h1>
  <input placeholder="Enter URL to shorten" className="w-full h-10 px-3 rounded-lg bg-slate-600 text-white" />
  <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-700 hover:text-white ease-in-out duration-300">
    Create URL
  </button>
</div>
</main>

Styling is done, now we need to add some logic to make it do things. Add async function named Submit into Home page component. Just like this.

export default function Home() {
async function Submit() {}
return (
  <main className="w-full h-[95vh] flex justify-center items-center">
    <div className=" flex flex-col items-center w-1/4 h-fit outline-1 outline outline-gray-500 bg-gray-900 rounded-lg gap-10 py-5 px-3">
      <h1 className="text-3xl text-white font-bold" > ShortURL </h1>
      <input placeholder="Enter URL to shorten" className="w-full h-10 px-3 rounded-lg bg-slate-600 text-white" />
      <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-700 hover:text-white ease-in-out duration-300">
        Create URL
      </button>
    </div>
  </main>
);
}

And we need to link it to buttons onClick event, but by doing so we need to switch into client side rendering by adding 'use client' flag at the start and now we can link the onClick

'use client'
export default function Home() {
  async function Submit() {}
  return (
    <main className="w-full h-[95vh] flex justify-center items-center">
      <div className=" flex flex-col items-center w-1/4 h-fit outline-1 outline outline-gray-500 bg-gray-900 rounded-lg gap-10 py-5 px-3">
        <h1 className="text-3xl text-white font-bold" > ShortURL </h1>
        <input placeholder="Enter URL to shorten" className="w-full h-10 px-3 rounded-lg bg-slate-600 text-white" />
        <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-700 hover:text-white ease-in-out duration-300" onClick={() => Submit()}>
          Create URL
        </button>
      </div>
    </main>
  );
}

Accessing data of input field can be tricky. To make it easier for us we're gonna use useRef hook, but first we need to import it from react. Then create instance and link it to the input field. Code should look like this

'use client'
import {useRef} from 'react';
export default function Home() {
const inputRef = useRef(null);
async function Submit() {}
return (
  <main className="w-full h-[95vh] flex justify-center items-center">
    <div className=" flex flex-col items-center w-1/4 h-fit outline-1 outline outline-gray-500 bg-gray-900 rounded-lg gap-10 py-5 px-3">
      <h1 className="text-3xl text-white font-bold" > ShortURL </h1>
      <input ref={inputRef} placeholder="Enter URL to shorten" className="w-full h-10 px-3 rounded-lg bg-slate-600 text-white" />
      <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-700 hover:text-white ease-in-out duration-300" onClick={() => Submit()}>
        Create URL
      </button>
    </div>
  </main>
);
}

For testing we are going to turn Submit() into simple alert function with use of regex to check if the url is valid. By doing this:

'use client'
import {useRef} from 'react';
export default function Home() {
const inputRef = useRef<HTMLInputElement>(null);
async function Submit() {
  //regex pattern
  const _pattern = "^(https?:\\/\\/)[\\da-z\\.-]+\\.[a-z]{2,}([\\/\\w .-]*)(:[\\w]+)?(\\?[\\w=&]+)?(#\\w+)?\\/?$";
  //initializing regex
  const _regex = new RegExp(_pattern);

  // get value of input field
  const urlInput = inputRef.current?.value || null
  if (!urlInput) return;

  if (!_regex.test(urlInput)) {
    alert("Invalid URL");
    return;
  } else {
    alert("Valid URL");
    return;
  }
}
return (
  <main className="w-full h-[95vh] flex justify-center items-center">
    <div className=" flex flex-col items-center w-1/4 h-fit outline-1 outline outline-gray-500 bg-gray-900 rounded-lg gap-10 py-5 px-3">
      <h1 className="text-3xl text-white font-bold" > ShortURL </h1>
      <input ref={inputRef} placeholder="Enter URL to shorten" className="w-full h-10 px-3 rounded-lg bg-slate-600 text-white" />
      <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-700 hover:text-white ease-in-out duration-300" onClick={() => Submit()}>
        Create URL
      </button>
    </div>
  </main>
);
}

5. Generating ID for url

To create API routes we need to create folder src/api in which we create a folder with the name of the api route that we want. For this example we are going to create a folder named src/api/url. In this folder we need to create a file route.ts with basic GET, POST methods. Such as this:

export async function GET(req: Request) {
return new Response("Hello, Next.js! GET");
}
export async function POST(req: Request) {
return new Response("Hello, Next.js! POST");
}

Now we have backend and frontend, but they don't know about each other. To make them communicate we need to edit index page src/app/page.tsx by replacing alert("Valid URL"); with this:

// post request header
const headers = {
method: 'POST',
headers: {
 accept: 'application/json',
},
body: JSON.stringify({url: urlInput}),
}
// Fetch call with headers
const response = await fetch(process.env.NEXT_PUBLIC_BASE_URL+'/api/url', headers)
// Check if response code isn't 200
if (!response.ok) {
alert('Failed to fetch data')
return
}
// json data from api
const data = await response.json()
alert(process.env.NEXT_PUBLIC_BASE_URL + "/" + data.id)

So the whole file src/app/page.tsx should look like this:

'use client'
import {useRef} from 'react';
export default function Home() {
const inputRef = useRef<HTMLInputElement>(null);
async function Submit() {
  //regex pattern
  const _pattern = "^(https?:\\/\\/)[\\da-z\\.-]+\\.[a-z]{2,}([\\/\\w .-]*)(:[\\w]+)?(\\?[\\w=&]+)?(#\\w+)?\\/?$";
  //initializing regex
  const _regex = new RegExp(_pattern);

  // get value of input field
  const urlInput = inputRef.current?.value || null
  if (!urlInput) return;

  if (!_regex.test(urlInput)) {
    alert("Invalid URL");
    return;
  } else {
    // post request header
    const headers = {
      method: 'POST',
      headers: {
       accept: 'application/json',
      },
      body: JSON.stringify({url: urlInput}),
    }
    // Fetch call with headers
    const response = await fetch(process.env.NEXT_PUBLIC_BASE_URL+'/api/url', headers)
    // Check if response code isn't 200
    if (!response.ok) {
      alert('Failed to fetch data')
      return
     }
    // json data from api
    const data = await response.json()
    alert(process.env.NEXT_PUBLIC_BASE_URL + "/" + data.id)
    return;
  }
}
return (
  <main className="w-full h-[95vh] flex justify-center items-center">
    <div className=" flex flex-col items-center w-1/4 h-fit outline-1 outline outline-gray-500 bg-gray-900 rounded-lg gap-10 py-5 px-3">
      <h1 className="text-3xl text-white font-bold" > ShortURL </h1>
      <input ref={inputRef} placeholder="Enter URL to shorten" className="w-full h-10 px-3 rounded-lg bg-slate-600 text-white" />
      <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-700 hover:text-white ease-in-out duration-300" onClick={() => Submit()}>
        Create URL
      </button>
    </div>
  </main>
);
}

In POST request we will be creating id associated with url and returning it. To do so we need basic a function to generate HEX number. Like this:

function generateId() {
  const min = 1;
  const max = 1099511637775;
  return Math.floor(Math.random() * (max - min + 1)) + min;           
}

Now let's create a simple algorithm to get request body, create an id (hex number) and return that id back to frontend.

function generateId() {
  const min = 1;
  const max = 1099511637775;
  return Math.floor(Math.random() * (max - min + 1)) + min;           
}

export async function GET(req: Request) {
  return new Response("Hello, Next.js! GET");
}

export async function POST(req: Request) {
  try {
    const data = await req.json()
    const { url } = data
    let id = generateId().toString(16)  
    return new Response(JSON.stringify({id: id}), { status: 200 })
  } catch (error) {
    return new Response('Error', { status: 500 })
  }
}

Now that we have a way to create an id, we need to save it. To do so we will use our RedisLib that we've created in the begining. To use it we need to import it and then get Redis instance.

import RedisLib from '@/lib/redis'

function generateId() {
  const min = 1;
  const max = 1099511637775;
  return Math.floor(Math.random() * (max - min + 1)) + min;           
}

export async function GET(req: Request) {
  return new Response("Hello, Next.js! GET");
}

export async function POST(req: Request) {
  try {
    const data = await req.json()
    const { url } = data

    const redis = RedisLib.getInstance().getClient();

    let id = generateId().toString(16)  
    return new Response(JSON.stringify({id: id}), { status: 200 })
  } catch (error) {
    return new Response('Error', { status: 500 })
  }
}

To tie it all together first we need to check if the url already exists in redis cache. If it does we need to return it, otherwise we need to create an id for it and save it into redis for 4h. After that we are going to return that id. Implementation for this part is below:

import RedisLib from '@/lib/redis'

function generateId(): number {
  const min = 1;
  const max = 1099511627775
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

export async function GET(req: Request) {
return new Response("Hello, Next.js! GET");
}

export async function POST(req: Request) {
  try {
    const data = await req.json()
    const { url } = data

    const redis = RedisLib.getInstance().getClient();
    let id = await redis.get(url);
    if (id) {
        return new Response(JSON.stringify({id: id}), { status: 200 })
    } else {

      let id = generateId().toString(16)
    
      const time = 60*60*4;
      await redis.set(id, url, 'EX', time)
      await redis.set(url, id, 'EX', time)

      return new Response(JSON.stringify({id: id}), { status: 200 })
    }
  } catch (error) {
      return new Response('Error', { status: 500 })
  }
}

6. Dynamic path for lookup and lookup API

Now that we can generate id and store it into redis.We need a way to look it up. Best way to do that is by using Next.js dynamic routes. There are two types [id] and [slug], where id is for number and slug is for string. For this case we are going to use [slug], because we have a hex number.

Creating [slug] route is very simple, we just need to add it to src/app/[slug] folder. In it we need to create page.tsx file with basic code just like this:

export default function Page({ params }: { params: { slug: string } }) {
    return <h1>Dynamic page : {params.slug}</h1>;
}

We are going to be fetching data from API. It's a good idea to handle errors with src/app/[slug]/error.tsx. For that we can reuse code from src/app/error.tsx

'use client'
import { useEffect } from 'react'

export default function Error({
 error,
}: {
 error: Error & { digest?: string }
 reset: () => void
}) {
 useEffect(() => {
  console.error(error)
 }, [error])

 return (
  <div className="w-full h-[90vh] flex flex-col justify-center items-center gap-10">
   <h2 className="text-3xl font-bold">Something went wrong!</h2>
   <button className="text-xl bg-blue-400 rounded-xl px-4 py-2 text-black hover:bg-blue-500" onClick={() => window.location.reload()}>
    Try again
   </button>
   <button className="text-xl bg-red-400 rounded-xl px-4 py-2 text-black hover:bg-red-500" onClick={() => window.history.back()}>
    Go Back
   </button>
   <div className="flex flex-col justify-center items-center gap-1">
    <code>Name: {error.name}</code>
    <code>Message: {error.message}</code>
   </div>
  </div>
 )
}

Now we can start fetching data. To do that let's use useEffect hook and create a simple callback function. Firstly to use any React hooks we need to switch into client component and then import useEffect hook.

'use client'
import { useEffect } from 'react';

export default function Page({ params }: { params: { slug: string } }) {
    useEffect(() => {},[])
    return <h1>Dynamic page : {params.slug}</h1>;
}

We will use URL parameters and GET method on api/url path, so we don't have to create a separate api route. The first step is to create headers for our request, then assemble an url with parameter id and fetch it.Then we need to check if we got any errors. If we did not, we will redirect the user to the url.

'use client'
import { useEffect } from "react";

export default function Page({ params }: { params: { slug: string } }) {
  async function GetUrl() {
    const headers = {
      method: "GET",
      headers: {
        accept: "application/json",
      },
    };
    const response = await fetch(
      process.env.NEXT_PUBLIC_BASE_URL + "/api/url?id=" + params.slug,
      headers
    );

    if (!response.ok) {
        window.location.replace(process.env.NEXT_PUBLIC_BASE_URL || '')
    }

    const data = await response.json();
    window.location.replace(data.url);
    return;
  }
  useEffect(() => {}, []);

  return <h1>Dynamic page :{params.slug}</h1>;
}

Now the only thing left to do is to call the function in useEffect hook and add it into dependencies array.

'use client'
import { useEffect } from "react";

export default function Page({ params }: { params: { slug: string } }) {
  async function GetUrl() {
    const headers = {
      method: "GET",
      headers: {
        accept: "application/json",
      },
    };
    const response = await fetch(
      process.env.NEXT_PUBLIC_BASE_URL + "/api/url?id=" + params.slug,
      headers
    );

    if (!response.ok) {
        window.location.replace(process.env.NEXT_PUBLIC_BASE_URL || '')
    }

    const data = await response.json();
    window.location.replace(data.url);
    return;
  }
  useEffect(() => {GetUrl()}, [GetUrl]);

  return <h1>Dynamic page :{params.slug}</h1>;
}

Moving to API we will be modifying GET function in src/api/url/route.ts by firtly parsing the url to get the id parameter.

import RedisLib from '@/lib/redis'

function generateId(): number {
  const min = 1;
  const max = 1099511627775
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

export async function GET(req: Request) {
    try {
        const path = new URL(req.url)
        const id = path.searchParams.get('id')
        if (!id) throw new Error('No id provided')

        return new Response(JSON.stringify({id: id}), { status: 200 })

    } catch (error) {
        return new Response('Error', { status: 500 })
    }
}

export async function POST(req: Request) {
  try {
    const data = await req.json()
    const { url } = data

    const redis = RedisLib.getInstance().getClient();
    let id = await redis.get(url);
    if (id) {
        return new Response(JSON.stringify({id: id}), { status: 200 })
    } else {

      let id = generateId().toString(16)
    
      const time = 60*60*4;
      await redis.set(id, url, 'EX', time)
      await redis.set(url, id, 'EX', time)

      return new Response(JSON.stringify({id: id}), { status: 200 })
    }
  } catch (error) {
      return new Response('Error', { status: 500 })
  }
}

Next we will get instance of our RedisLib and check if we have id in cache. Based on that we will return the url or throw 404 error code

import RedisLib from '@/lib/redis'

function getRandomNumber(): number {
    const min = 1;
    const max = 1099511627775
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

export async function GET(req: Request){
    try {
        const path = new URL(req.url)
        const id = path.searchParams.get('id')
        if (!id) throw new Error('No id provided')

        const redis = RedisLib.getInstance().getClient();
        
        let url = await redis.get(id);

        if (id) {
            return new Response(JSON.stringify({url: url}), { status: 200 })
        } else {
            return new Response(JSON.stringify({message: 'No url found'}), { status: 404 })
        }

    } catch (error) {
        return new Response('Error', { status: 500 })
    }
}


export async function POST(req: Request) {
    try {
        const data = await req.json()
        const { url } = data
        
        const redis = RedisLib.getInstance().getClient();

        let id = await redis.get(url);

        if (id) {
            return new Response(JSON.stringify({id: id}), { status: 200 })
        } else {
   
            let id = getRandomNumber().toString(16)
            
            await redis.set(id, url, 'EX', 60 * 60 * 4)
            await redis.set(url, id, 'EX', 60 * 60 * 4)
            
            return new Response(JSON.stringify({id: id}), { status: 200 })
        }
    } catch (error) {
        return new Response('Error', { status: 500 })
    }
}

But because we use dynamic route that calls the api. We can run into caching problem. To solve that we will turn caching off with this constant.

export const dynamic = 'force-dynamic'

We need to set it at the begining of our code just like this

import RedisLib from '@/lib/redis'
export const dynamic = 'force-dynamic'

function getRandomNumber(): number {
    const min = 1;
    const max = 1099511627775
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

export async function GET(req: Request){
    try {
        const path = new URL(req.url)
        const id = path.searchParams.get('id')
        if (!id) throw new Error('No id provided')

        const redis = RedisLib.getInstance().getClient();
        
        let url = await redis.get(id);

        if (id) {
            return new Response(JSON.stringify({url: url}), { status: 200 })
        } else {
            return new Response(JSON.stringify({message: 'No url found'}), { status: 404 })
        }

    } catch (error) {
        return new Response('Error', { status: 500 })
    }
}


export async function POST(req: Request) {
    try {
        const data = await req.json()
        const { url } = data
        
        const redis = RedisLib.getInstance().getClient();

        let id = await redis.get(url);

        if (id) {
            return new Response(JSON.stringify({id: id}), { status: 200 })
        } else {
   
            let id = getRandomNumber().toString(16)
            
            await redis.set(id, url, 'EX', 60 * 60 * 4)
            await redis.set(url, id, 'EX', 60 * 60 * 4)
            
            return new Response(JSON.stringify({id: id}), { status: 200 })
        }
    } catch (error) {
        return new Response('Error', { status: 500 })
    }
}