How to make a URL shortner using SvelteKit.
Tags: javascript, webdev, redis, sveltekit
Published: 2022-07-23 20:11:00


Read if: 
- No reasons needed

**Resources**
- Source Code : [Github](https://github.com/theetherGit/shrink-me)
- Live Example : [shrink.theether.live](https://www.shrink.theether.live/)
- Inspiration : @prajyu [How to make a URL shortner.](https://dev.to/prajyu/how-to-make-a-url-shortner-1a20)

**Introduction**
This is a project guide I'll suggest you to follow it step by step by yourself. In this article you will learn how to make a url shortner using Svelte, SvelteKit, Redis and TailwindCSS. 

**Initialization**
We are going to start with sveltekit starter project. Then we gonna setup TailwindCSS (if you don't know how to read my [Using TailwindCSS in SvelteKit to make a Design System : Part One](https://dev.to/theether0/using-tailwindcss-in-sveltekit-to-make-a-design-system-part-one-4k3p)).

- Required package other than above setup (npm i package-name)
```bash
   1. redis
   2. dotenv
```
- Create a `.env` file for your environment variables. In this project we gonna use Redis and I'm using [Railway](https://railway.app/) which is a PaaS provider for DB's.
```javascript
// .env

REDIS_URL=redis://localhost:6379 // Use your own it's for example.
```

- Create a new file in `src` directory by the name `hooks.ts`.

```javascript
// hooks.ts
import dotenv from 'dotenv';

dotenv.config();
```
This is gonna load your environment variables in svelteKit.

**Connecting server to Redis**
> Make sure you have installed it in your project if you haven't then run this command in your terminal```bash
npm i redis
```
- Create a `lib` folder in `src` directory and in `lib` folder create a file `redisConnection.ts`. In this file we going to handle our `Redis` connection and going to add some basic function to `set` or `get` values to/from `Redis`.

```typescript
// redisConnection.ts

import { createClient } from 'redis';
import log from './log';

const client = createClient({ url: process.env.REDIS_URL as string });

let connectPromise: Promise<void> | undefined;
let errorOnce = true;
async function autoConnect(): Promise<void> {
	if (!connectPromise) {
		errorOnce = true;
		connectPromise = new Promise((resolve, reject) => {
			client.once('error', (err) => reject(new Error(`Redis: ${err.message}`)));
			client.connect().then(resolve, reject);
		});
	}
	await connectPromise;
}
client.on('error', (err) => {
	if (errorOnce) {
		log.error('Redis:', err);
		errorOnce = false;
	}
});
client.on('connect', () => {
	log('Redis up');
});
client.on('disconnect', () => {
	connectPromise = undefined;
	log('Redis down');
});
async function get<T>(key: string): Promise<T | undefined>;
async function get<T>(key: string, fallback: T): Promise<T>;
async function get<T>(key: string, fallback?: T): Promise<T | undefined> {
	await autoConnect();
	const value = await client.get(key);
	if (value === null) {
		return fallback;
	}
	return JSON.parse(value);
}
async function set(
	key: string,
	value: unknown,
	options?: { ttl: number } // TTL in seconds
): Promise<void> {
	const data = JSON.stringify(value);
	const config = options ? { EX: options.ttl } : {};
	await autoConnect();
	await client.set(key, data, config);
	await client.publish(key, data);
}
const storage = {
	get,
	set
};
export default storage;
```

> Here I'm using logger too, so skip logger for now. If you wanna learn how i did it check out [Multiplayer Dice Game by bfanger on github](https://github.com/bfanger/multiplayer-dice-game). He is using `socket.io`, `redis` and many other things, you gonna learn alot from this.

- **Explaination**

-- Create a client for `redis` in `node`. We gonna use `createClient` from `redis`. Which needs your `REDIS_URL` that we added in `.env`.
```javascript
const client = createClient({ url: process.env.REDIS_URL as string });
```
-- We gonna add a autoConnect function which will connect to `Redis` when we gonna `set` or `get` a value in redis.
```javascript
let connectPromise: Promise<void> | undefined;
let errorOnce = true;
async function autoConnect(): Promise<void> {
	if (!connectPromise) {
		errorOnce = true;
		connectPromise = new Promise((resolve, reject) => {
			client.once('error', (err) => reject(new Error(`Redis: ${err.message}`)));
			client.connect().then(resolve, reject);
		});
	}
	await connectPromise;
}
```

-- Now we added three initiators which checks our connection with redis and respond according to that, e.g. `error`, `connected` and `disconnected`.
```javascript
client.on('error', (err) => {
	if (errorOnce) {
		log.error('Redis:', err);
		errorOnce = false;
	}
});
client.on('connect', () => {
	log('Redis up');
});
client.on('disconnect', () => {
	connectPromise = undefined;
	log('Redis down');
});
```

-- We are now going to add our functional and need function to get data from redis. 

```javascript
async function get<T>(key: string): Promise<T | undefined>;
async function get<T>(key: string, fallback: T): Promise<T>;
async function get<T>(key: string, fallback?: T): Promise<T | undefined> {
	await autoConnect();
	const value = await client.get(key);
	if (value === null) {
		return fallback;
	}
	return JSON.parse(value);
}
```
> Here we first defined the types of function. Function takes one parameter `key` which is required to find value in `redis`. First we going to connect with `redis` using our auto connect function then going to get the value using our provided `key` and then we going to return parsed JSON.

-- Now we gonna add our set function which going to help us in adding value to `redis`. Set function takes two parameters `key` and `value`. Key is needed to be unique which help us to get the item from `redis`.

```javascript
async function set(
	key: string,
	value: unknown,
	options?: { ttl: number } // TTL in seconds
): Promise<void> {
	const data = JSON.stringify(value);
	const config = options ? { EX: options.ttl } : {};
	await autoConnect();
	await client.set(key, data, config);
	await client.publish(key, data);
}
```
-- Finally, we going to export them so we can use them anywhere in our project.

```javascript
const storage = {
	get,
	set
};
export default storage;
```

That's all we need to add in redis to work in our project.

**Adding Frontend and Shadow endpoints**

Here we going to add our html and tailwindcss to make our input box and Buttons.

```html
// index.svelte

<script lang="ts">
	import { page } from '$app/stores';
	import Clipboard from '$lib/Clipboard.svelte';
	let url: string;
	let isURLGenerated: boolean = false;
	let isInvalidURL: boolean = false;

	function isValidHttpUrl(string) {
		let url;

		try {
			url = new URL(string);
		} catch (_) {
			return false;
		}

		return url.protocol === 'http:' || url.protocol === 'https:';
	}

	async function getURL() {
		if (isValidHttpUrl(url)) {
			const r = (Math.random() + 1).toString(36).substring(7);
			const redirectURL = `${$page.url}${r}`;
			isInvalidURL = false;
			const data = { key: r, url: url };
			await fetch('/', {
				method: 'POST',
				headers: {
					accept: 'application/json'
				},
				body: JSON.stringify(data)
			});
			url = redirectURL;
			isURLGenerated = true;
		} else isInvalidURL = true;
	}
</script>

<svelte:head>
	<title>Shrink | Home</title>
</svelte:head>
<div class="bg-white w-screen h-screen flex flex-col justify-center text-center">
	<h1 class="text-6xl p-4 text-fuchsia-500 font-bold">Shrink Me, Web.</h1>
	<div class="flex justify-center w-full p-10">
		<div class="mb-3 xl:w-2/4">
			{#if isInvalidURL}
				<div
					id="alert-border-2"
					class="flex p-4 mb-4 bg-red-100 border-t-4 border-red-500 dark:bg-red-200"
					role="alert"
				>
					<svg
						class="flex-shrink-0 w-5 h-5 text-red-700"
						fill="currentColor"
						viewBox="0 0 20 20"
						xmlns="http://www.w3.org/2000/svg"
						><path
							fill-rule="evenodd"
							d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
							clip-rule="evenodd"
						/></svg
					>
					<div class="ml-3 text-sm font-medium text-red-700">You have typed wrong URL.</div>
				</div>
			{/if}
			<div class="input-group relative flex items-stretch w-full mb-4">
				<input
					type="text"
					class="form-control relative flex-auto block w-full border-b-2 px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding transition ease-in-out m-0 focus:outline-none duration-300 focus:text-gray-700 focus:bg-white {isURLGenerated
						? 'border-emerald-600 focus:border-emerald-600'
						: 'border-fuchsia-600 focus:border-fuchsia-600'}"
					placeholder="Paste or type your URL"
					aria-label="url"
					aria-describedby="url"
					bind:value={url}
				/>
				<Clipboard
					text={url}
					let:copy
					on:copy={() => {
						console.log('Has Copied');
					}}
				>
					{#if isURLGenerated}
						<button
							on:click={copy}
							class="inline-block px-6 py-2 border-2 border-emerald-600 bg-emerald-600 text-white font-medium text-xs leading-tight uppercase hover:bg-white hover:text-emerald-600 transition duration-300 ease-in-out"
							type="button"
							id="button-copy">Copy</button
						>
						<button
							on:click={() => {
								url = '';
								isURLGenerated = false;
							}}
							class="inline-block px-6 py-2 border-2 border-rose-600 bg-rose-600 text-white font-medium text-xs leading-tight uppercase hover:bg-white hover:text-rose-600 transition duration-300 ease-in-out"
							type="button"
							id="button-reset">Reset</button
						>
					{:else}
						<button
							on:click={getURL}
							class="inline-block px-6 py-2 border-2 border-fuchsia-600 bg-fuchsia-600 text-white font-medium text-xs leading-tight uppercase hover:bg-white hover:text-fuchsia-600 transition duration-300 ease-in-out"
							type="button"
							id="button-addon3">Shrink</button
						>
					{/if}
				</Clipboard>
			</div>
			<div class="flex justify-center items-center">
				<div class="spinner-grow inline-block w-8 h-8 bg-fuchsia-600 rounded-full  opacity-0" />
			</div>
		</div>
	</div>
</div>

```
- **Explanation**
Here, I'm going to explain the basic functionality and request making to shadow endpoint. Please understand the html and tailwind part from my earlier posts.

-- First we going to focus on pur `script` tag.
```javascript
<script lang="ts">
	import { page } from '$app/stores';
	import Clipboard from '$lib/Clipboard.svelte';
	let url: string;
	let isURLGenerated: boolean = false;
	let isInvalidURL: boolean = false;

	function isValidHttpUrl(string) {
		let url;

		try {
			url = new URL(string);
		} catch (_) {
			return false;
		}

		return url.protocol === 'http:' || url.protocol === 'https:';
	}

	async function getURL() {
		if (isValidHttpUrl(url)) {
			const r = (Math.random() + 1).toString(36).substring(7);
			const redirectURL = `${$page.url}${r}`;
			isInvalidURL = false;
			const data = { key: r, url: url };
			await fetch('/', {
				method: 'POST',
				headers: {
					accept: 'application/json'
				},
				body: JSON.stringify(data)
			});
			url = redirectURL;
			isURLGenerated = true;
		} else isInvalidURL = true;
	}
</script>
```
> Here, I have to define are parameters. Those are URL : which is provided by the user, isURLGenerated : checking if url generated or not (we need it to change are buttons from generate to copy), isInvalidURL : is defined for html to activate alert if url is not valid.

> `isValidHttpUrl` : This function help to verify our URL which is provided by user is a valid URL or not.

> `getURL` : This function generates are short url. First, I have added check for `isValidHttpUrl` if this is valid we gonna proceed otherwise we gonna return `isInvalidURL = true`. If URL is valid then going to generate a random string and after that we going to make a request to our shadow endpoint/api to save our `key` generated string and `url` provided by the user.

- **Shadow Endpoint or API**
```typescript
// index.ts

import storage from '$lib/redisConnection';
import type { RequestHandler } from '@sveltejs/kit';

export const POST: RequestHandler = async ({ request }) => {
	const data = await request.json();
	await storage.set(data.key, data.url);
	return {
		body: {
			status: 200,
			error: null
		}
	};
};
```
-- Here I have added our `POST` method handling which takes a request parameter from which we get the data we provide while making request from `index.svelte`. Here we save that data to redis using set function.

This is all we need to generate a valid short URL and save in redis. Now we gonna `how we redirect user to URL when visit using short URL generated by you`.

**Getting original URL and Redirecting to the URL**
In this section we are going to learn how we get parameter from URL and getting data from redis and then redirecting to original URL.

-- Add a new file in `routes` directory `[slug].ts` and add following lines of code

```typescript
// [slug].ts

import storage from '$lib/redisConnection';
import type { RequestHandler } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ params }) => {
	if (params.slug.length > 3) {
		return {
			headers: {
				Location: await storage.get(params.slug)
			},
			status: 302
		};
	} else
		return {
			headers: {
				Location: '/'
			},
			status: 302,
			error: new Error('Short URL doesn't exist')
		};
};
```

> Here we have added a `GET` method which will be called when we gonna visit any short url [example](https://www.shrink.theether.live/znq63). We are using {params} which is an inbuilt dictionary which contains our slug of the page.

>I have added condition to check length of the slug(generated string in `index.svelte`) and using that we gonna query our redis using get function and going to get the value which is our saved original URL. I have added `Location` in headers which helps us to redirect to that URL and added a status or redirect. If condition fails its going to redirected to home of our URL Shortner. 

That how we gonna get and redirect to the original URL.

That is the end of the article. You should check all the mentioned resources and links which going to help you understand this post much better. 

This is me writing for you. If you wanna ask or suggest anything please put it in comment.