DEV Community

Cover image for Create a Url Shortener using NextJs, Tailwind CSS and Strapi
Shada for Strapi

Posted on • Originally published at strapi.io

Create a Url Shortener using NextJs, Tailwind CSS and Strapi

Author: Chibuike Nwachukwu

Ever since the dawn of the internet age, links have played an integral part in how we interact and visit web pages. It has acted as a means of access to various resources online. Its human-friendly readable format as opposed to knowing a webpage's actual IP address has contributed immensely to its broad usage.

Its popularity brought with it some slight issues, as more people started creating and hosting websites for their various needs, ranging from a company to blogs, to events, there has been a rapid increase in domain name/URLs. People now have to remember long URLs in order to visit sites, later. This is one major issue that URL Shortener came to eliminate.

Simply put, a URL shortener is a service that reduces the length of a URL. It achieves this by saving this URL to its records, assigning an alias (short text) to it, then redirecting any request made to this alias on its record to the host URL (Webpage).

This tutorial will show you how to build a URL Shortener Service using Next.js and Tailwind CSS for frontend and Strapi Headless CMS for backend. You can find the link to the completed frontend code here and as well as the completed backend code here.

Advantages of Using a Shortened URL

It is necessary that before we proceed further into this article, we have a clearer understanding of what a URL Shortener does. Some of the advantages include:

  1. Aesthetic appeal: Isn't it great to see an invite for an event bearing just the event name in the link, as opposed to a long link, containing the date, location in its URL.
  2. Analytics tracking: As an application that can be deployed in multiple places, it reduces the cost of running a large number of customer care representatives.
  3. Link Swapping: Since most URL shortener services allow the editing of the real URL, we can always be consistent in the link we share while being flexible with the webpage the link leads to.
  4. Easier to remember: Since most shorteners, domains are short e.g bit.ly, TinyURL; it is easier for people to recall the URL once it is shared with them.

Prerequisites

Before starting this tutorial, you need to have:

  • Node.js installed on your local machine (v14+) - Check this tutorial for instructions on how to install Node.js
  • Basic understanding of Strapi - Get started with this quick guide
  • Basic knowledge of Next.js
  • Basic knowledge of Tailwind CSS

What is Next Js

Next.js is an awesome React framework for building highly dynamic applications. It comes with pre-rendering, server-side rendering, automatic code-splitting amongst many other great features out of the box.

What is Tailwind CSS

Tailwind CSS is a utility-first CSS framework for rapidly building custom user interfaces. With Tailwind CSS, we write our CSS directly in our HTML classes. This is quite useful as we don't need to import an external stylesheet or use a separate library for UI designs.

What is Strapi

Strapi is a Node.js open-source headless CMS that allows us to develop APIs and manage content easily without the hassle of building out a project from scratch. It allows for customization and self-hosting as opposed to the rigid traditional CMS we are used to.

We can easily build out APIs faster and consume the contents via APIs using any REST API client or GraphQL.

Scaffolding a Strapi Project

To set up a new Strapi Project is quite straightforward as running these few commands:

npx create-strapi-app strapi-tutorial-shortner --quickstart
Enter fullscreen mode Exit fullscreen mode

Change strapi-tutorial-shortner to the preferred name of your project.

This would install and create a Strapi project locally.

After installation, the browser would open a page on localhost:1337, which would prompt to set up the first admin account to proceed with Strapi.

Building the Shortener Collection

Next, we will create a new collection type that will store the details of each question and their respective answers.

Hence, we create a collection type called shortner that has these four fields fields: alias, url, visit, user.

Clicking “Continue” would bring up another screen to select the fields for this collection. Choose the “Text” field from the list and provide alias as its name.

Next, we select the Short Text type in the Base Settings, as alias is meant to be a short string.

Next, we proceed to the “Advanced settings” tab and check the “Required field” box to ensure this field is required. Also, we check the “Unique field” box to prevent having the same alias in our record.

We click on the Add another field to add the answer field. Below is a table showing the properties for all the fields we need in this collection:

Field Name Field Type Required Unique
alias Short text true true
url Short text true false
visit Number (integer) false false
user Number (integer) true false

Allowing Public access

By default, whenever you create an API, they’re all going to be restricted from public access. We need to tell Strapi that you’re okay with exposing these checked endpoints to the public. Go to Settings > Users & Permissions Plugin ****** > Roles and click to edit the Public Role. Next, scroll down to Permissions > Shortner and tick the find checkbox.

We would also be exposing some endpoints to the authenticated user. Click the “Go Back” button and then click edit the Authenticated Role. The image below shows the endpoints which would be exposed to the authenticated user: **

Customizing the Shortner Controller

We customize the shortner controller which is found at src/api/shortner/controllers/shortner.js to add more functionality to it, to cater to our needs.

For the find method, we have the following scenarios:

  1. If it is called by an authenticated user, we only show records that belong to that user. This would generally be called by the front end when it wants to display records on the dashboard.
  2. If it is called by an unauthenticated user, we filter based on the query provided, this would generally be called by the front end when it wants to check if an alias exists in our record. If found we also increment the visit field in the shortner collection to track the visit.

For the create method; we use it to create a new record as well as assign the user field in the shortner collection to the authenticated user’s ID. Hence only authenticated users have access to this endpoint.

For the delete method; we use it to remove a record from the shortner collection, only a user that created a record is allowed to delete it. That also means only authenticated users have access to this endpoint.

Hence replace the code of the file with the code below:

    'use strict';
    /**
     *  shortner controller
     */
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::shortner.shortner', ({ strapi }) => ({
        async find(ctx) {
            let { query } = ctx;
            const user = ctx.state.user;
            let entity;
            if (user) {
                query = { user: { '$eq': user.id } }
                entity = await strapi.service('api::shortner.shortner').find({ filters: query });
            } else {
                query = { alias: { '$eq': query.alias } }
                entity = await strapi.service('api::shortner.shortner').find({ filters: query });
                if (entity.results.length !== 0) {
                    let id = entity.results[0].id
                    let visit = Number(entity.results[0].visit) + 1
                    await strapi.service('api::shortner.shortner').update(id, { data: { visit } });
                }
            }
            const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
            return this.transformResponse(sanitizedEntity);
        },
        async create(ctx) {
            const { data } = ctx.request.body;
            const user = ctx.state.user;
            let entity;
            data.user = user.id
            entity = await strapi.service('api::shortner.shortner').create({ data });
            const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
            return this.transformResponse(sanitizedEntity);
        },
        async delete(ctx) {
            let { id } = ctx.params;
            const user = ctx.state.user;
            let entity;
            let query = { user: { '$eq': user.id }, id: { '$eq': id } }
            entity = await strapi.service('api::shortner.shortner').find({ filters: query });
            if (entity.results.length === 0) {
                return ctx.badRequest(null, [{ messages: [{ id: 'You can delete someone else content' }] }]);
            }
            entity = await strapi.service('api::shortner.shortner').delete(id);
            const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
            return this.transformResponse(sanitizedEntity);
        },
    }));
Enter fullscreen mode Exit fullscreen mode

Scaffolding a Next.js project

Creating a Next.js app

To create a Next.js app, open your terminal, cd into the directory you’d like to create the app in, and run the following command:

npx create-next-app -e with-tailwindcss nextjs-shortner
Enter fullscreen mode Exit fullscreen mode

This would also configure Tailwind CSS with the project.

Running the Next.js Development Server

Next, we cd into the newly created directory, in our case that would be nextjs-shortner:

    cd nextjs-shortner
Enter fullscreen mode Exit fullscreen mode

After which we start up the development server by running this command:

    npm run dev
Enter fullscreen mode Exit fullscreen mode

If everything was set up fine, the Next.js server should now be running on localhost:3000 and we should see the following page on our browser:

Building Next.js Components

Next, we open up any text editor of our choice to write code for the rest of the application. Open up the installed project and we should have a folder structure such as this:

Folder Structure

To begin the design of the interface, we would remove all of the code in the index.js file and add the code below:

    import React, { useContext, useEffect } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    export default function Home() {
      const { isLoggedIn, user } = useContext(MyContext)
      const router = useRouter()
      useEffect(() => {
        if (isLoggedIn) {
         return router.push("/dashboard");
        }
        return router.push("/login");
      }, [isLoggedIn])
      return null
    }
Enter fullscreen mode Exit fullscreen mode

The above code makes use of React Context API to check if the user is authenticated. This determines which page gets shown to the user.

As can also be seen, we are importing a context file from the lib folder. We need to create this file. Go to the root of the project and create a folder called lib, then create a file called context.js in it.

Inside this context.js, we create the context, and also assign the default value of false to isLoggedIn.

    import React from 'react';
    const MyContext = React.createContext({ isLoggedIn: false });
    export default MyContext;
Enter fullscreen mode Exit fullscreen mode

Next, we head straight to create the two files we would conditionally be redirecting to the Login and Register files.

Next.js creates routes for files under the pages directory. The route points to the files themselves, their documentation explains it quite well. This means if we created a file called dashboard.js in the pages directory, we can access it by visiting localhost:3000/dashboard without needing to create an additional routing mechanism. Great right?

So, we simply create the two files (Login and Register) in this pages directory.

However, before we dive into these two pages, we would need to first update the content of the _app.js page.

This page is used by Next.js to initialize other pages, so we could use it to achieve persistent layout between pages, custom error handling, and in our case, keeping a global state among pages. Read more about this page here.

Create an _app.js file if it doesn't exist in the pages director. Remove everything in it and replace its code with the code below:

    import React, { useState, useEffect } from 'react';
    import MyContext from '../lib/context';
    import Cookie from "js-cookie";
    import 'tailwindcss/tailwind.css'
    export default function _App({ Component, pageProps }) {
      const [user, setUser] = useState(null)
      const [urls, setUrls] = useState([])

      useEffect(() => {
        const jwt = Cookie.get("jwt");
        if (jwt) {
          fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/me`, {
            headers: {
              Authorization: `Bearer ${jwt}`,
            },
          }).then(async (res) => {
            if (!res.ok) {
              Cookie.remove("jwt");
              setUser(null);
            }

            const user = await res.json();
            setUser(user);
          });
        }
      }, [])
      return (
        <MyContext.Provider
          value={{
            user: user,
            isLoggedIn: !!user,
            setUser,
            setUrls,
            urls
          }}
        >
          <Component {...pageProps} />
        </MyContext.Provider>
        )
    }
Enter fullscreen mode Exit fullscreen mode

The above code simply wraps itself around all pages and handles the global state by using React Context API.

We also use the js-cookie npm package to store our token, to persist a session even when the user refreshes the page.

To get it installed, we run the npm i js-cookie command.

Then we import it into our file.

import Cookie from "js-cookie";
Enter fullscreen mode Exit fullscreen mode

We make use of the useEffect hook to check if there is a stored token (meaning the user is logged in). If a token is found, we make a request to the Strapi API to get the details of this user. If there are no errors, we store the user in the user state, else we delete the token and assign null to the user state.

    useEffect(() => {
        const jwt = Cookie.get("jwt");
        if (jwt) {
          fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/me`, {
            headers: {
              Authorization: `Bearer ${jwt}`,
            },
          }).then(async (res) => {
            if (!res.ok) {
              Cookie.remove("jwt");
              setUser(null);
            }
            const user = await res.json();
            setUser(user);
          });
        }
    }, [])
Enter fullscreen mode Exit fullscreen mode

As can also be seen we have two states, user and urls, created using the useState hook. We have seen the use of the user state already, we use the urls state to store the array of shorteners that we got from the Strapi API.

Lastly, we wrap the Component with the Context API provider, similar to how we do it in Redux. Next, we set the values of the Context API to our state variables as well as functions such as setUrls, setUser so that other pages/components would be able to access them.

Finally, we create a new variable called isLoggedIn, this would be used to check if there exists an authenticated user.

    return (
        <MyContext.Provider
          value={{
            user: user,
            isLoggedIn: !!user,
            setUser,
            setUrls,
            urls
          }}
        >
          <Component {...pageProps} />
        </MyContext.Provider>
    )
Enter fullscreen mode Exit fullscreen mode

Now, we would go on to create the Register file. Add the content below to the newly created pages/register.js file:

    import Head from 'next/head'
    import Link from 'next/link'
    import React, { useState, useContext, useEffect } from 'react';
    import MyContext from '../lib/context';
    import { register } from '../lib/auth'
    import { useRouter } from "next/router"; 
    export default function Register() {
      const { isLoggedIn, setUser } = useContext(MyContext)
      const router = useRouter()

      let [username, setUsername] = useState("");
      let [email, setEmail] = useState("");
      let [password, setPassword] = useState("")
      const [loading, setLoading] = useState(false);
      const [errors, setErrors] = useState({});
      useEffect( () => {
        if (isLoggedIn) {
         return router.push("/dashboard");
        }
      }, [isLoggedIn])
      const submit = async () => {
        if(!username.trim()) return setErrors({ username: "Username must not be empty"})
        if(!email) return setErrors({ email: "Email must not be empty"})
        if(!password) return setErrors({ password: "Password must not be empty"})

        setLoading(true);
        const reg = await (register(username, email, password))
        setLoading(false);
        if(reg.jwt){
          setUser(reg.user);
          router.push('/dashboard')
        }else{
          setErrors({ server: reg?.error?.message || 'Error from server' });
        }
      }
      return (
        <div className="flex flex-col items-center justify-center min-h-screen py-2">
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
            <h1 className="text-6xl font-bold text-blue-600">
              Url Shortener
            </h1>

            <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
              <form className="w-full max-w-lg mt-8" onSubmit={(e) => { e.preventDefault(); submit() }}>
              <div className="flex flex-wrap -mx-3 mb-2">
                  <div className="w-full px-3 mb-6 md:mb-0">
                    <input onChange={ (e) => setUsername(e.target.value)}  placeholder="Enter username" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.username ? "border-red-500" : "border-gray-200"}`} id="grid-username" type="text" />
                    {errors.username ? (
                      <p className="text-red-500 text-xs italic">{errors.username}</p>
                    ) : ''}
                  </div>
                </div>
                <div className="flex flex-wrap -mx-3 mb-2">
                  <div className="w-full px-3 mb-6 md:mb-0">
                    <input onChange={ (e) => setEmail(e.target.value)}  placeholder="Enter email" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.email ? "border-red-500" : "border-gray-200"}`} id="grid-email" type="email" />
                    {errors.email ? (
                      <p className="text-red-500 text-xs italic">{errors.email}</p>
                    ) : ''}
                  </div>
                </div>
                <div className="flex flex-wrap -mx-3 mb-6">
                  <div className="w-full px-3">
                    <span className={`w-full inline-flex items-center rounded border border-r-1  text-gray-700 mb-2 text-sm  focus:outline-none focus:bg-white focus:border-gray-500 ${errors.password ? "border-red-500 " : " border-gray-200"}`}>
                      <input onChange={ (e) => setPassword(e.target.value)}  placeholder="******************" className="appearance-none block rounded w-full py-3 px-4 leading-tight" id="grid-password" type='password' />
                    </span>
                    {errors.password ? (
                      <p className="text-red-500 text-xs italic">{errors.password}</p>
                    ) : ''}
                  </div>
                </div>
                {errors.server ? (
                      <p className="text-red-500 text-xs italic">{errors.server}</p>
                    ) : ''}
                <div className="flex flex-row flex-wrap justify-between">

                  <span className="text-blue-600 hover:text-gray-600 pt-2 md:p-6"> <Link href="/login">Back to Login?</Link></span>
                  <button disabled={loading} className={`w-full md:w-1/2 mt-3 flex justify-center hover:bg-gray-200 hover:text-gray-900 rounded-md px-3 py-3 uppercase ${loading ? "bg-gray-200  text-black cursor-not-allowed" : "bg-gray-900  text-white cursor-pointer"}`}>
                    {loading ? (
                      <>
                        loading &nbsp;...
                      </>
                    ) : 'Register'}
                  </button>
                </div>
              </form>
            </div>
          </main>
        </div>
      )
    }
Enter fullscreen mode Exit fullscreen mode

The above code registers users to the platform, allowing us to create a secured page later for people to come in, create, manage and track their shortened URLs.

We also use the useContext hook to get our state values and functions:

    import React, { useState, useContext, useEffect } from 'react';
    import MyContext from '../lib/context';
    const { isLoggedIn, setUser } = useContext(MyContext)
Enter fullscreen mode Exit fullscreen mode

Also, we use the useEffect hook to apply middleware on the page, so that only the unauthenticated user can access the page. We achieve this using the isLoggedIn state:

    import React, { useState, useContext, useEffect } from 'react';
    useEffect( () => {
        if (isLoggedIn) {
          return router.push("/dashboard");
        }
    }, [isLoggedIn])
Enter fullscreen mode Exit fullscreen mode

If a user is authenticated, we redirect them back to their dashboard.

The submit method handles user registration, validates and sets the user state to the signed user if successful and then redirects the user to their dashboard:

    const submit = async () => {
        if(!username.trim()) return setErrors({ username: "Username must not be empty"})
        if(!email) return setErrors({ email: "Email must not be empty"})
        if(!password) return setErrors({ password: "Password must not be empty"})

        setLoading(true);
        const reg = await (register(username, email, password))
        setLoading(false);
        if (reg.jwt) {
          setUser(reg.user);
          router.push('/dashboard')
        } else{
          setErrors({ server: reg?.error?.message || 'Error from server' });
        }
      }
Enter fullscreen mode Exit fullscreen mode

As can be seen, we make use of a function called register, which handles the sending of a request to the Strapi API:

import { register } from '../lib/auth'
const reg = await register(username, email, password)
Enter fullscreen mode Exit fullscreen mode

We proceed to create this file (auth.js) in the lib folder. This file makes authenticated requests to our API and handles other auth-related functions like logout. Add the content below into the file:

    import Cookie from "js-cookie";
    const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337";

    export const register = async (username, email, password) => {
         try {
            let response = await fetch(`${API_URL}/api/auth/local/register`, {
                method: 'POST',
                body: JSON.stringify({ username, email, password }),
                headers: {
                    'Content-Type': 'application/json'
                },
            });
            response = await response.json();
            if (response) {
                Cookie.set("jwt", response.jwt);
            }
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }

    };
    export const login = async (identifier, password) => {
       try {
            let response = await fetch(`${API_URL}/api/auth/local`, {
                method: 'POST',
                body: JSON.stringify({ identifier, password }),
                headers: {
                    'Content-Type': 'application/json'
                },
            });
            response = await response.json();
            if (response) {
                Cookie.set("jwt", response.jwt);
            }
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }

    };
    export const logout = () => {
        Cookie.remove("jwt");
    };
Enter fullscreen mode Exit fullscreen mode

As can be seen, we use the js-cookie package to assign the jwt once a user is logged in or registered, as well as delete this token once the user logs out.

This also leads us to create a .env at the root of our project. Inside it, we would have:

 NEXT_PUBLIC_API_URL=http://localhost:1337
Enter fullscreen mode Exit fullscreen mode

Now, we would go on to create the Login file. Add the content below to the newly create pages/login.js file:

    import Head from 'next/head'
    import React, { useState, useEffect, useContext } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    import { login } from '../lib/auth'
    import Link from 'next/link'
    export default function Login() {

      let [email, setEmail] = useState("");
      let [password, setPassword] = useState("")
      const [loading, setLoading] = useState(false);
      const [errors, setErrors] = useState({});
      const { isLoggedIn, setUser } = useContext(MyContext)
      const router = useRouter()
      const signIn = async () => {
        if(!email) return setErrors({ email: "Email must not be empty"})
        if(!password) return setErrors({ password: "Password must not be empty"})

        setLoading(true);
        const reg = await (login(email, password))
        setLoading(false);
        if(reg.jwt){
          setUser(reg.user);
          router.push('/')
        }else{
          setErrors({ server: reg?.error?.message || 'Error from server' });
        }
      }
      useEffect( () => {
        if (isLoggedIn) {
         return router.push("/dashboard");
        }
      }, [isLoggedIn])

      return (
        <div className="flex flex-col items-center justify-center min-h-screen py-2">
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
            <h1 className="text-6xl font-bold text-blue-600">
              Url Shortener
            </h1>

            <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
              <form className="w-full max-w-lg mt-8" onSubmit={(e) => { e.preventDefault(); signIn(email, password) }}>
                <div className="flex flex-wrap -mx-3 mb-2">
                  <div className="w-full px-3 mb-6 md:mb-0">
                    <input onChange={ (e) => setEmail(e.target.value)} placeholder="Enter email..." className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.email ? "border-red-500" : "border-gray-200"}`} id="grid-email" type="email" />
                    {errors.email ? (
                      <p className="text-red-500 text-xs italic">{errors.email}</p>
                    ) : ''}
                  </div>
                </div>
                <div className="flex flex-wrap -mx-3 mb-6">
                  <div className="w-full px-3">
                    <span className={`w-full inline-flex items-center rounded border border-r-1 text-gray-700 mb-2 text-sm  focus:outline-none focus:bg-white focus:border-gray-500 ${errors.password ? "border-red-500 " : " border-gray-200"}`}>
                      <input onChange={ (e) => setPassword(e.target.value)} placeholder="******************" className="appearance-none block rounded w-full py-3 px-4 leading-tight" id="grid-password" type='password' />
                    </span>
                    {errors.password ? (
                      <p className="text-red-500 text-xs italic">{errors.password}</p>
                    ) : ''}
                  </div>
                </div>
                {errors.server ? (
                      <p className="text-red-500 text-xs italic">{errors.server}</p>
                    ) : ''}
                <div className="flex flex-row flex-wrap justify-between">
                  <button disabled={loading} className={`w-full md:w-1/2 mt-3 flex justify-center align-center hover:bg-gray-200 hover:text-gray-900 rounded-md px-2 py-3 uppercase ${loading ? "bg-gray-200  text-black cursor-not-allowed" : "bg-gray-900  text-white cursor-pointer"}`}>
                    {loading ? (
                      <>
                        loading &nbsp;...
                      </>
                    ) : 'LOG IN'}
                  </button>
                  <span className="text-blue-600 hover:text-gray-600 pt-2 md:p-6"> <Link href="/register">Register</Link></span>
                </div>
              </form>
            </div>
          </main>

        </div>
      )
    }
Enter fullscreen mode Exit fullscreen mode

The above code allows users to login and get access to the secured dashboard. It is similar to the register, only that it doesn't create users but checks their existence in the record and authenticates them.

This also makes use of the lib/auth.js file which we have seen already.

The remaining pages we would be looking at now are the:

  1. Dashboard page: We would use this to handle the deletion and viewing of the shortened URLs.
  2. Add Url page: This is used to add a shortened URL.
  3. Alias page: This is used to redirect to the URL if the alias is found in our record.

Building the Dashboard Page

As discussed earlier, this page shows all created records, as well as enables the user to test them and delete them.

Proceed to create a file called dashboard.js in the pages folder pages/dashboard.js. Insert the code below as its content:

    import Head from 'next/head'
    import React, { useEffect, useContext, useState } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    import Link from 'next/link';
    import { logout } from '../lib/auth'
    import { get, deleteAlias } from '../lib/shortener'

    export default function Dashboard() {
        const { isLoggedIn, setUser, user, setUrls, urls } = useContext(MyContext)
        const router = useRouter()
        const getAll = async () => {
            let short = await get()
            if (!short) return
            setUrls(short?.data?.attributes?.results || null)
        }
        const deleteShort = async (id) => {
            if (!id) return
            let deleted = await deleteAlias(id)
            if (deleted.data && !deleted.error) {
                await getAll()
            }
        }
        useEffect(() => {
            if (!isLoggedIn) {
                return router.push("/login");
            }
            getAll()
        }, [urls.length])

        const signOut = () => {
            logout()
            setUser(null)
            router.push('/login')
        }

        return (
            <div className="flex flex-col items-center justify-center min-h-screen py-2">
                <Head>
                    <title>Dashboard</title>
                    <link rel="icon" href="/favicon.ico" />
                </Head>
                <header className="flex justify-between align-center p-4 h-32 w-full text-6xl font-bold text-blue-600">
                    <h1 className="text-6xl font-bold text-blue-600">
                        Url Shortener
                    </h1>
                    <span className="text-sm font-bold text-red-600 cursor-pointer" onClick={() => signOut()}>Logout</span>
                </header>
                <main className="flex flex-col items-center w-full mt-0 flex-1 px-8 text-center">

                    <p className="flex flex-wrap w-full text-lg font-bold">
                        Welcome {user?.username || ""}
                    </p>
                    <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
                        <div className="shadow  border-b w-full  overflow-hidden border-gray-200 sm:rounded-lg">
                            <table className="min-w-full divide-y divide-gray-200">
                                <thead>
                                    <tr>
                                        <th scope="col" className="px-6 py-3 bg-gray-50 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                                            Url
                                        </th>
                                        <th scope="col" className="px-6 py-3 bg-gray-50 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                                            Alias/Shortned
                                        </th>
                                        <th scope="col" className="px-6 py-3 bg-gray-50 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                                            No of hits
                                        </th>
                                        <th scope="col" className="px-6 py-3 bg-gray-50">
                                            <span className="sr-only">Remove</span>
                                        </th>
                                    </tr>
                                </thead>
                                <tbody className="bg-white divide-y divide-gray-200">
                                    {(!urls || urls.length == 0) && (
                                        <tr>
                                            <td colSpan="3" className="px-2 py-4 whitespace-nowrap cursor-pointer">
                                                No record found
                                            </td>
                                        </tr>
                                    )}
                                    {urls && urls.map(short =>
                                    (
                                        <tr className="hover:bg-gray-200" key={short.id}>
                                            <td className="px-2 py-4 whitespace-nowrap cursor-pointer" title = "Open Url" onClick={() => { window.open(`${short.url}`, 'blank') }}>
                                                <div className="text-sm text-gray-900">{short?.url || 'N/A'}</div>
                                            </td>
                                            <td className="px-2 py-4 whitespace-nowrap cursor-pointer" title = "Test Alias" onClick={() => { window.open(`/${short.alias}`, 'blank') }}>
                                                <div className="text-sm text-gray-900">{short?.alias || 'N/A'}</div>
                                            </td>
                                            <td className="px-2 py-4 whitespace-nowrap cursor-pointer">
                                                <span className="px-2  text-xs leading-5 font-semibold rounded-full ">
                                                    <div className="text-sm text-gray-500">
                                                        {short?.visit || 0}
                                                    </div>
                                                </span>
                                            </td>
                                            <td className="px-2 py-2 whitespace-nowrap text-center text-sm font-medium">
                                                <button onClick={() => deleteShort(short.id)} className="text-red-600 hover:text-red-900 mx-1">Delete</button>
                                            </td>
                                        </tr>
                                    )
                                    )}
                                </tbody>
                            </table>
                        </div>
                    </div>
                </main>
                <Link href="/addUrl">
                    <button className="absolute rounded-full text-white font-bold text-lg p-2 bg-blue-800 w-12 h-12 m-4 right-0 bottom-0 hover:bg-blue-400"> + </button>
                </Link>
            </div>
        )
    }
Enter fullscreen mode Exit fullscreen mode

In a nutshell, we use this to show users their shortened URLs. As can be seen, we use the useEffect hook to help prevent unauthenticated users from accessing the page.

Also, we have functions to handle deleting a record, getting all records, and logout users.

The functions that handle the delete and get, call a central shortener helper file called shortener.js:

    import { get, deleteAlias } from '../lib/shortener'
Enter fullscreen mode Exit fullscreen mode

We use this file to handle all shortener related functionalities. Hence, we proceed to create this file inside the lib folder, lib/shortener.js, and add the code below as its content:

    import Cookie from "js-cookie";
    const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337";

    export const get = async () => {
        const token = Cookie.get("jwt");
        try {
            let response = await fetch(`${API_URL}/api/shortners`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
            });
            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    };

    export const getSingle = async (alias) => {
        try {
            let response = await fetch(`${API_URL}/api/shortners?alias=${alias}`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json'
                },
            });
            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    }

    export const create = async (url, alias) => {
        const token = Cookie.get("jwt");
        try {
            let response = await fetch(`${API_URL}/api/shortners`, {
                method: 'POST',
                body: JSON.stringify({ data: { url, alias } }),
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
            });
            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    };

    export const deleteAlias = async (id) => {
        const token = Cookie.get("jwt");

        try {
            let response = await fetch(`${API_URL}/api/shortners/${id}`, {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
            });

            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    };
Enter fullscreen mode Exit fullscreen mode

Building the Add URL Page

As discussed earlier, this page handles the creation of shortened URLs. Proceed to create a file called addUrl.js inside the pages folder, pages/addUrl.js.

Next, add the content below as its new content:

    import Head from 'next/head';
    import Link from 'next/link';
    import React, { useEffect, useContext, useState } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    import { logout } from '../lib/auth';
    import { create } from '../lib/shortener';

    export default function AddUrl() {
        const { isLoggedIn, setUser } = useContext(MyContext)
        const [url, setUrl] = useState("");
        const [alias, setAlias] = useState("");
        const [loading, setLoading] = useState(false);
        const [errors, setErrors] = useState({});
        const router = useRouter();
        useEffect(() => {
            if (!isLoggedIn) {
                return router.push("/login");
            }
        }, [isLoggedIn]);
        const shorten = async () => {
            if (!url) return setErrors({ url: "Url must not be empty" })
            if (!alias) return setErrors({ alias: "Alias must not be empty" })
            setLoading(true);
            const short = await(create(url, alias))
            setLoading(false);
            if (short.data && !short.error) {
                router.push('/dashboard')
            } else {
                setErrors({ server: short?.error?.message || 'Error from server' });
            }
        }
        const signOut = () => {
            logout();
            setUser(null);
            router.push('/login');
        }
        return (
            <div className="flex flex-col items-center justify-center min-h-screen py-2">
                <Head>
                    <title>Add Url</title>
                    <link rel="icon" href="/favicon.ico" />
                </Head>
                <header className="flex justify-between align-center p-4 h-32 w-full text-6xl font-bold text-blue-600">
                    <h1 className="text-6xl font-bold text-blue-600">
                        Url Shortener
                    </h1>
                    <span className="text-sm font-bold text-red-600 cursor-pointer" onClick={() => signOut()}>Logout</span>
                </header>
                <main className="flex flex-col items-center w-full mt-0 flex-1 px-8 text-center">

                    <p className="flex flex-wrap w-full text-lg font-bold">
                        Fill the form
                    </p>
                    <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
                        <form className="w-full max-w-lg mt-8" onSubmit={(e) => { e.preventDefault(); shorten() }}>
                            <div className="flex flex-wrap -mx-3 mb-2">
                                <div className="w-full px-3 mb-6 md:mb-0">
                                    <input onChange={(e) => setUrl(e.target.value)} placeholder="Enter url" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.url ? "border-red-500" : "border-gray-200"}`} id="grid-url" type="text" />
                                    {errors.url ? (
                                        <p className="text-red-500 text-xs italic">{errors.url}</p>
                                    ) : ''}
                                </div>
                            </div>
                            <div className="flex flex-wrap -mx-3 mb-2">
                                <div className="w-full px-3 mb-6 md:mb-0">
                                    <input onChange={(e) => setAlias(e.target.value)} placeholder="Enter alias" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.alias ? "border-red-500" : "border-gray-200"}`} id="grid-alias" type="text" />
                                    {errors.alias ? (
                                        <p className="text-red-500 text-xs italic">{errors.alias}</p>
                                    ) : ''}
                                </div>
                            </div>
                            {errors.server ? (
                                <p className="text-red-500 text-xs italic">{errors.server}</p>
                            ) : ''}
                            <div className="flex flex-row flex-wrap justify-between">
                                <span className="text-blue-600 hover:text-gray-600 pt-2 md:p-6"> <Link href="/dashboard"> Back to Dashboard</Link></span>
                                <button disabled={loading} className={`w-full md:w-1/2 mt-3 flex justify-center hover:bg-gray-200 hover:text-gray-900 rounded-md px-3 py-3 uppercase ${loading ? "bg-gray-200  text-black cursor-not-allowed" : "bg-gray-900  text-white cursor-pointer"}`}>
                                    {loading ? (
                                        <>
                                            loading &nbsp;...
                                        </>
                                    ) : 'Shorten'}
                                </button>
                            </div>
                        </form>
                    </div>
                </main>
            </div>
        )
    }
Enter fullscreen mode Exit fullscreen mode

This is quite straightforward to understand, we simply make use of the shortener file in the lib folder to make a request to our Strapi API to add the record.

We also make use of the useEffect hook to prevent unauthenticated users from accessing the page.

Building the Alias Page

This page is the one which is in charge of checking if the alias exists in our record and redirecting the user accordingly.

Subsequently, if an alias is found in our record, the Strapi API records that as a visit to the alia, giving us the ability to see analytics ofa particular alias.

We proceed to create a file called [alias].js in the pages folder, pages/[alias].js. If this looks strange, check how to build pages with dynamic routes in the Next.js.

Next, insert the content below as the content of this file:

    import { useRouter } from "next/router";
    import { useEffect } from "react";
    import { getSingle } from "../lib/shortener";

    const AliasView = ({ error }) => {
        const router = useRouter()
        useEffect(() => {
            if (error) {
                return router.push('/')
            }
        }, [])
        return null
    };

    export async function getServerSideProps({ params }) {
        const url = await getSingle(params.alias)
        if (url.data && (url.data?.attributes?.results[0] || false) && !url.error) {
            return {
                redirect: {
                    destination: url.data.attributes.results[0].url,
                    permanent: false,
                },
            }
        }
        return {
            props: { error: "error" }
        }
    }

    export default AliasView;

As can be seen, we use the `getServerSideProps` to check if the alias exists in our record, if so we redirect to the actual URL.


    export async function getServerSideProps({ params }) {
        const url = await getSingle(params.alias)
        if (url.data && (url.data?.attributes?.results[0] || false) && !url.error) {
            return {
                redirect: {
                    destination: url.data.attributes.results[0].url,
                    permanent: false,
                },
            }
        }
        return {
            props: { error: "error" }
        }
    }

If we cant find it, we pass the `error` prop to the actual component:


    return {
        props: { error: "error" }
    }

Then in our component, we redirect the user to the home page since the alias isn't in our record. 


    const AliasView = ({ error }) => {
        const router = useRouter()
        useEffect(() => {
            if (error) {
                return router.push('/')
            }
        }, [])
        return null
    };
Enter fullscreen mode Exit fullscreen mode

If the user is authenticated, they would get redirected to the Dashboard page, else they would get redirected to the Login page. Did we implement this feature on the Index.js page? Yes, we did!

And that's it for the code section of the frontend part of this tutorial. If you have come this far, I must say you are doing great!

Enough for all this technical stuff, in the next section, we would be seeing a demo of the finished app.

Testing Finished App

The finished app looks like this:

https://www.youtube.com/watch?v=T7e_A6bUejM

Conclusion

The benefits a URL Shortener brings can’t be over-emphasized. This is seen in the rapid outbreak of companies playing in this space. You can go ahead to add more features to this project, by simply forking the repo (found at beginning of this tutorial) and getting your hands dirty. It's best left to the imagination what you can achieve!

This tutorial demonstrated how easily you can build a URL Shortener Service in about 20 minutes using technologies like Next.js and Strapi. Once again, Strapi has shown us that it is equal to the task when it comes to creating great APIs!

Top comments (1)

Collapse
 
bampuska profile image
Tai • Edited

Since you create 2 project folders "strapi-tutorial-shortner" and "nextjs-shortner", the question is how to bind them together to make a working one?