How to make a URL shortner using SvelteKit.

How to make a URL shortner using SvelteKit.

Read if:

  • No reasons needed

Resources

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).

  • Required package other than above setup (npm i package-name)
   1. redis
   2. dotenv
Enter fullscreen mode Exit fullscreen mode
  • Create a .env file for your environment variables. In this project we gonna use Redis and I'm using Railway which is a PaaS provider for DB's.
// .env

REDIS_URL=redis://localhost:6379 // Use your own it's for example.
Enter fullscreen mode Exit fullscreen mode
  • Create a new file in src directory by the name hooks.ts.
// hooks.ts
import dotenv from 'dotenv';

dotenv.config();
Enter fullscreen mode Exit fullscreen mode

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

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.
// 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;
Enter fullscreen mode Exit fullscreen mode

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. 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.

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

-- We gonna add a autoConnect function which will connect to Redis when we gonna set or get a value in redis.

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;
}
Enter fullscreen mode Exit fullscreen mode

-- Now we added three initiators which checks our connection with redis and respond according to that, e.g. error, connected and disconnected.

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');
});
Enter fullscreen mode Exit fullscreen mode

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

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

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);
}
Enter fullscreen mode Exit fullscreen mode

-- Finally, we going to export them so we can use them anywhere in our project.

const storage = {
    get,
    set
};
export default storage;
Enter fullscreen mode Exit fullscreen mode

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.

// 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>

Enter fullscreen mode Exit fullscreen mode
  • 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.

<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>
Enter fullscreen mode Exit fullscreen mode

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
// 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
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

-- 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

// [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')
        };
};
Enter fullscreen mode Exit fullscreen mode

Here we have added a GET method which will be called when we gonna visit any short url example. 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.

© 2023 EtherCorps. All rights reserved.