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:
- 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.
- 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.
- 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.
- 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
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:
- 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.
- 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);
},
}));
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
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
After which we start up the development server by running this command:
npm run dev
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:
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
}
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;
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>
)
}
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";
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);
});
}
}, [])
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>
)
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 ...
</>
) : 'Register'}
</button>
</div>
</form>
</div>
</main>
</div>
)
}
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)
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])
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' });
}
}
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)
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");
};
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
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 ...
</>
) : '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>
)
}
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:
- Dashboard page: We would use this to handle the deletion and viewing of the shortened URLs.
- Add Url page: This is used to add a shortened URL.
- 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>
)
}
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'
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' }
}
};
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 ...
</>
) : 'Shorten'}
</button>
</div>
</form>
</div>
</main>
</div>
)
}
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 can’t 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
};
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!