DEV Community

arnu515
arnu515

Posted on • Edited on

Build an instagram clone with Strapi and Svelte (PART 3)

Hello! I'm back with Part 3 of this tutorial! As promised, in this part, we'll deal with authenticated requests like creating posts, adding comments and uploading images. Let's get started!

If you get stuck, or just want the source code, it is available on Github

Fixing fontawesome

Our icons don't work. They just show a box. This is because we haven't added the icon font yet. We did add the CSS, but we also need to put the fonts in public/webfonts. Download the font from CDNJS and save it as public/webfonts/fa-solid-900.woff2. Your icons should now show as normal

Dealing with authentication

When we authenticate with Strapi, we get back a JWT token. This token can then be used to authenticate ourselves in other requests without needing to send any email/password. Let's update our Auth.svelte component:

<!-- src/components/Auth.svelte -->

<script lang="ts">
    import Error from "./ErrorAlert.svelte";
    import { fade } from "svelte/transition";
    import { getContext } from "svelte";
    import axios from "axios";

    type AuthMode = "login" | "register";

    export let authMode: AuthMode = "register";
    export let next: string = "/posts";
    const apiUrl: string = getContext("apiUrl");

    let loginError: string | null = null;
    let registerError: string | null = null;

    let email = "";
    let password = "";
    let cpassword = "";
    let username = "";

    function login() {
        email = email.trim();
        password = password.trim();

        if (!email || !password) {
            loginError = "Fill out all fields!";
            return;
        }
        loginError = null;

        axios
            .post(apiUrl + "/auth/local", {
                identifier: email,
                password,
            })
            .then(({ data }) => {
                localStorage.setItem("JWT", data.jwt);
                localStorage.setItem("user", JSON.stringify(data.user));
                // Using window.location.href instead of router.redirect to refresh the page
                // so that components like Navbar update too
                window.location.href = next;
            })
            .catch((err) => {
                if (err.response) {
                    loginError = "";
                    for (let message of err.response.data.message[0].messages) {
                        loginError += `${message.message}\n`;
                    }
                } else loginError = err;
            });
    }

    function register() {
        email = email.trim();
        password = password.trim();
        cpassword = cpassword.trim();
        username = username.trim();

        if (!email || !password || !cpassword || !username) {
            registerError = "Fill out all fields!";
            return;
        }

        if (password !== cpassword) {
            registerError = "Passwords don't match";
            return;
        }
        registerError = null;

        axios
            .post(apiUrl + "/auth/local/register", {
                email,
                username,
                password,
            })
            .then(({ data }) => {
                localStorage.setItem("JWT", data.jwt);
                localStorage.setItem("user", JSON.stringify(data.user));
                // Using window.location.href instead of router.redirect to refresh the page
                // so that components like Navbar update too
                window.location.href = next;
            })
            .catch((err) => {
                if (err.response) {
                    registerError = "";
                    for (let message of err.response.data.message[0].messages) {
                        registerError += `${message.message}\n`;
                    }
                } else registerError = err;
            });
    }
</script>

<style>
    .auth-box {
        width: 40%;
        margin: 1rem auto;
    }

    @media (max-width: 600px) {
        .auth-box {
            width: 80%;
        }
    }
</style>

<div class="w3-container">
    <div class="w3-card-4 w3-border w3-border-black auth-box">
        <div class="w3-bar w3-border-bottom w3-border-gray">
            <button
                style="width: 50%"
                on:click={() => (authMode = 'login')}
                class="w3-bar-item w3-button w3-{authMode === 'login' ? 'blue' : 'white'} w3-hover-{authMode === 'login' ? 'blue' : 'light-gray'}">Login</button>
            <button
                style="width: 50%"
                on:click={() => (authMode = 'register')}
                class="w3-bar-item w3-button w3-{authMode === 'register' ? 'blue' : 'white'} w3-hover-{authMode === 'register' ? 'blue' : 'light-gray'}">Register</button>
        </div>
        <div class="w3-container">
            <h3>{authMode === 'login' ? 'Login' : 'Register'}</h3>

            {#if authMode === 'login'}
                <form on:submit|preventDefault={login} in:fade>
                    {#if loginError}
                        <Error message={loginError} />
                    {/if}
                    <div class="w3-section">
                        <label for="email">Email</label>
                        <input
                            type="email"
                            bind:value={email}
                            placeholder="Enter your email"
                            id="email"
                            class="w3-input w3-border w3-border-black" />
                    </div>
                    <div class="w3-section">
                        <label for="password">Password</label>
                        <input
                            type="password"
                            bind:value={password}
                            placeholder="Enter your password"
                            id="password"
                            class="w3-input w3-border w3-border-black" />
                    </div>
                    <div class="w3-section">
                        <button
                            class="w3-button w3-blue w3-hover-blue w3-border w3-border-blue">Login</button>
                        <button
                            class="w3-button w3-white w3-hover-light-gray w3-border w3-border-black"
                            on:click={() => (authMode = 'register')}>Register</button>
                    </div>
                </form>
            {:else}
                <form on:submit|preventDefault={register} in:fade>
                    {#if registerError}
                        <Error message={registerError} />
                    {/if}
                    <div class="w3-section">
                        <label for="username">Username</label>
                        <input
                            type="text"
                            bind:value={username}
                            placeholder="Enter a username"
                            id="username"
                            class="w3-input w3-border w3-border-black" />
                    </div>
                    <div class="w3-section">
                        <label for="email">Email</label>
                        <input
                            type="email"
                            bind:value={email}
                            placeholder="Enter your email"
                            id="email"
                            class="w3-input w3-border w3-border-black" />
                    </div>
                    <div class="w3-section">
                        <label for="password">Password</label>
                        <input
                            type="password"
                            bind:value={password}
                            placeholder="Enter a password"
                            id="password"
                            class="w3-input w3-border w3-border-black" />
                    </div>
                    <div class="w3-section">
                        <label for="cpassword">Confirm Password</label>
                        <input
                            type="password"
                            bind:value={cpassword}
                            placeholder="Re-enter that password"
                            id="cpassword"
                            class="w3-input w3-border w3-border-black" />
                    </div>
                    <div class="w3-section">
                        <button
                            class="w3-button w3-blue w3-hover-blue w3-border w3-border-blue">Register</button>
                        <button
                            class="w3-button w3-white w3-hover-light-gray w3-border w3-border-black"
                            on:click={() => (authMode = 'login')}>Login</button>
                    </div>
                </form>
            {/if}
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Now, our app will go to the Strapi server, get the JWT token, and put it in our local storage for later use. Let's create src/auth.ts which will have some helper functions to get and remove the token:

// src/auth.ts

import type { User } from "./types";

export function getToken(): string | null {
    return localStorage.getItem("JWT") || null;
}

export function clearToken() {
    localStorage.removeItem("JWT");
}

export function getUserId(): number | null {
    let user: string | User = localStorage.getItem("user");
    if (!user) return null;
    user = JSON.parse(user);
    return (user as User).id;
}

export function getUser(): User | null {
    let user: string | User = localStorage.getItem("user");
    if (!user) return null;
    user = JSON.parse(user);
    return user as User;
}
Enter fullscreen mode Exit fullscreen mode

Conditional rendering in the navbar

Let's use the helper methods from auth.ts to determine if we're logged in or not. We can use this in the navbar to only show the upload button (which I renamed to "New Post") if the user is logged in:

<!-- src/components/Navbar.svelte -->

<script lang="ts">
    import { slide } from "svelte/transition";
    import { getToken } from "../auth";

    const auth = !!getToken();
    let active = false;
</script>

<style>
    .toggler {
        display: none;
    }

    @media (max-width: 600px) {
        .logo {
            display: block;
            width: 100%;
        }
        .logo .toggler {
            float: right;
            display: initial;
        }
        .nav {
            display: flex;
            width: 100%;
            flex-direction: column;
        }

        .nav a {
            text-align: left;
        }
    }
</style>

<div class="w3-bar w3-blue">
    <div class="logo">
        <a
            href="/"
            class="w3-bar-item w3-text-white w3-button w3-hover-blue">Quickstagram</a>
        <button
            class="toggler w3-button w3-blue w3-hover-blue"
            on:click={() => (active = !active)}>
            <i class="fas fa-{active ? 'times' : 'bars'}" /></button>
    </div>
    <div class="w3-right w3-hide-small">
        {#if auth}
            <a href="/new" class="w3-bar-item w3-button w3-hover-blue">New post</a>
            <a
                href="/logout"
                class="w3-bar-item w3-button w3-hover-blue">Logout</a>
        {:else}
            <a
                href="/auth?action=login"
                class="w3-bar-item w3-button w3-hover-blue">Login</a>
            <a
                href="/auth?action=register"
                class="w3-bar-item w3-button w3-purple w3-hover-purple">Register</a>
        {/if}
    </div>
    {#if active}
        <div class="w3-right nav w3-hide-large w3-hide-medium" transition:slide>
            {#if auth}
                <a href="/new" class="w3-bar-item w3-button w3-hover-blue">New
                    post</a>
                <a
                    href="/logout"
                    class="w3-bar-item w3-button w3-hover-blue">Logout</a>
            {:else}
                <a
                    href="/auth?action=login"
                    class="w3-bar-item w3-button w3-hover-blue">Login</a>
                <a
                    href="/auth?action=register"
                    class="w3-bar-item w3-button w3-purple w3-hover-purple">Register</a>
            {/if}
        </div>
    {/if}
</div>
Enter fullscreen mode Exit fullscreen mode

Automatic redirects

If the user is logged in, and they visit /auth, they can log in again. To prevent this, we'll automatically redirect them to /posts. We'll do this in both auth.svelte and index.svelte

<!-- src/routes/auth.svelte -->
<script lang="ts">
    import Auth from "../components/Auth.svelte";
    import router from "page";
    import { onMount } from "svelte";
    import { getToken } from "../auth";

    export const params = {};
    export let queryString: { action: "login" | "register"; next: string };

    onMount(() => {
        if (getToken()) router.redirect(queryString.next || "/posts");
    });
</script>

<Auth authMode={queryString.action} next={queryString.next} />
Enter fullscreen mode Exit fullscreen mode
<!-- src/routes/index.svelte -->

<script lang="ts">
    import { onMount } from "svelte";
    import { getToken } from "../auth";
    import router from "page";

    import Auth from "../components/Auth.svelte";

    export const queryString = {};
    export const params = {};

    onMount(() => {
        if (getToken()) router.redirect("/posts");
    });
</script>

<div class="w3-container">
    <h1 class="w3-center w3-xxxlarge">Quickstagram</h1>
    <p class="w3-center w3-large w3-text-gray">Instagram, but quicker!</p>

    <div class="w3-center">
        <a
            href="/auth?action=register"
            class="w3-button w3-blue w3-border w3-border-blue w3-hover-blue">Register</a>
        <a
            href="/auth?action=login"
            class="w3-button w3-white w3-border w3-border-black w3-hover-white">Login</a>
    </div>

    <Auth />

    <div class="w3-center">
        <a
            href="/posts"
            class="w3-button w3-blue w3-border w3-border-blue w3-hover-blue">View
            posts</a>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Logging out

Logging out is very simple, we don't even need to contact Strapi for this! We just have to delete the token from our local storage. I've created a route called /logout which will do just that:

<!-- src/routes/logout.svelte -->

<script lang="ts">
    import { onMount } from "svelte";
    import { clearToken } from "../auth";

    export let queryString: { next: string };

    onMount(() => {
        clearToken();

        // Using window.location.href instead of router.redirect to refresh the page
        // so that components like Navbar update too
        window.location.href = queryString.next || "/";
    });
</script>

<h1 class="w3-center w3-xxlarge">Logging you out...</h1>
Enter fullscreen mode Exit fullscreen mode

And as with all routes, we have to register it in App.svelte.

<!-- src/App.svelte -->

<script lang="ts">
    // ...
    import Logout from "./routes/logout.svelte";
    // ...

    router("/logout", setupRouteParams, () => (page = Logout));
    // ...
</script>

<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Adding comments

Let's focus on comments first, since they're easier :P. In src/components/onePost.svelte, let's add an input that allows us to add comments:

<!-- src/components/onePost.svelte -->

<script lang="ts">
    import axios from "axios";
    import { getContext } from "svelte";
    import router from "page";
    import { getToken } from "../auth";

    import type { Post, Comment as CommentType } from "../types";
    import Comment from "../components/Comment.svelte";
    import ErrorAlert from "../components/ErrorAlert.svelte";

    export let params: { username: string; postId: string };
    const apiUrl = getContext("apiUrl");
    const auth = !!getToken();

    let commentError: string | null = null;

    async function getPost(): Promise<Post> {
        try {
            const { data } = await axios.get<Post>(
                apiUrl + "/posts/" + params.postId
            );
            if (data.user)
                if (data.user.username !== params.username)
                    router.redirect("/404");
            return data;
        } catch (err) {
            if (err.response.status === 404) router.redirect("/404");
            else {
                console.log({ error: err });
                throw new Error(
                    "Request failed with status: " +
                        err.response.status +
                        "\nCheck the console for further details."
                );
            }
        }
    }

    async function getComments(post: Post): Promise<CommentType[]> {
        try {
            let comments: CommentType[] = [];
            for (let i = 0; i < post.comments.length; i++) {
                const { data } = await axios.get<CommentType>(
                    apiUrl + "/comments/" + post.comments[i].id
                );
                comments.push(data);
            }

            return comments;
        } catch (err) {
            if (err.response) {
                console.log({ err });
                if (err.response.status === 404) router.redirect("/404");
                else {
                    console.log({ error: err });
                    throw new Error(
                        "Request failed with status: " +
                            err.response.status +
                            "\nCheck the console for further details."
                    );
                }
            } else throw new Error(err);
        }
    }

    function newComment() {}
</script>

<style>
    #comment-form {
        display: grid;
        grid-template-rows: auto;
        grid-template-columns: 80% 20%;
        margin: 1rem 0;
    }

    .post {
        width: 50%;
        margin: 0 auto;
    }

    @media (max-width: 992px) {
        .post {
            width: 70%;
        }
    }

    @media (max-width: 600px) {
        .post {
            width: 90%;
        }
    }
</style>

{#await getPost()}
    <div class="w3-center w3-section w3-xxlarge w3-spin">
        <i class="fas fa-spinner" />
    </div>
{:then post}
    <div class="w3-card post">
        <a
            href={post.image[0].provider === 'local' && getContext('apiUrl') + post.image[0].url}><img
                src={post.image[0].provider === 'local' && getContext('apiUrl') + post.image[0].url}
                alt={post.image.alternativeText || 'Post image'}
                style="width: 100%" /></a>
        <div class="w3-container">
            <p class="w3-small w3-text-gray">
                <a
                    href="/@{post.user.username}"
                    style="text-decoration: none">@{post.user.username}</a>
            </p>
            <p>{post.content}</p>
        </div>
    </div>

    <div class="w3-card post w3-margin-top">
        <header class="w3-container w3-border-bottom w3-border-light-gray">
            <h3>Comments</h3>
        </header>
        <div class="w3-container">
            {#if auth}
                {#if commentError}
                    <ErrorAlert message={commentError} />
                {/if}
                <form on:submit|preventDefault={newComment} id="comment-form">
                    <input
                        type="text"
                        class="w3-input w3-border"
                        placeholder="Type your comment here"
                        id="comment" />
                    <button
                        class="w3-button w3-blue w3-hover-blue w3-border w3-border-blue"
                        type="submit">Add</button>
                </form>
            {/if}
            {#await getComments(post)}
                <div class="w3-center w3-section w3-xxlarge w3-spin">
                    <i class="fas fa-spinner" />
                </div>
            {:then comments}
                {#each comments as comment}
                    <Comment {comment} />
                {/each}
            {:catch err}
                <div
                    class="w3-panel w3-pale-red w3-padding w3-leftbar w3-border-red w3-text-red">
                    {err}
                </div>
            {/await}
        </div>
    </div>
{:catch err}
    <div
        class="w3-panel w3-pale-red w3-padding w3-leftbar w3-border-red w3-text-red">
        {err}
    </div>
{/await}

Enter fullscreen mode Exit fullscreen mode

Let's use Insomnia to add a comment:

Alt Text

Note that we have to specify the post and user id too. Automatically determining them is not possible unless we edit Strapi's code. Also, you need to provide a JWT to authenticate the request.

We've done this in insomnia, but now, let's add code to do it in our frontend. We'll modify our newComment function in onePost.svelte

// src/routes/onePost.svelte
// script tag

    function newComment(postId: number) {
        // "as HTMLInputElement" is supported in TypeScript only.
        const userId: number | null = getUserId();
        if (!userId) {
            window.location.href =
                "/auth?action=login&next=" + window.location.pathname;
            return;
        }

        const content = (
            (document.getElementById("comment") as HTMLInputElement).value || ""
        ).trim();
        if (!content) return;

        axios
            .post<Comment>(
                apiUrl + "/comments",
                {
                    content,
                    post: postId,
                    user: userId,
                },
                {
                    headers: {
                        Authorization: "Bearer " + getToken(),
                    },
                }
            )
            .then(() => window.location.reload())
            .catch((error) => {
                if (error.response) {
                    if (
                        error.response.status === 401 ||
                        error.response.status === 403
                    )
                        window.location.href =
                            "/auth?action=login&next=" +
                            window.location.pathname;
                    else {
                        commentError = "";
                        for (let message of error.response.data.message[0]
                            .messages) {
                            commentError += `${message.message}\n`;
                        }
                    }
                } else commentError = error;
            });
    }
Enter fullscreen mode Exit fullscreen mode

Let's test it!

Creating new posts

Let's now give the same treatment to posts! I'm going to create a /new route which will be occupied by newPost.svelte.

<!-- src/components/newPost.svelte -->

<script lang="ts">
    import { getContext, onMount } from "svelte";
    import { getToken, getUser } from "../auth";
    import ErrorAlert from "../components/ErrorAlert.svelte";
    import type { User } from "../types";

    const apiUrl = getContext("apiUrl");
    const user: User = getUser();
    onMount(() => {
        if (!getToken() || !user)
            window.location.href =
                "/auth?action=login&next=" + window.location.pathname;
    });

    let loading = false;
    let error: string | null = null;
    let file: File;
    let content = "";

    function chooseFile() {
        const input = document.createElement("input");
        input.type = "file";
        input.accept = "image/*";
        input.addEventListener("change", ({ target }) => {
            if ((target as HTMLInputElement).files.length === 1) {
                file = (target as HTMLInputElement).files[0];
            }
        });

        input.click();
    }

    function newPost() {}
</script>

{#if user}
    <h1 class="w3-center w3-xxxlarge">New post</h1>
    <p class="w3-center w3-text-gray">Logged in as: {user.username}</p>

    <div class="w3-card w3-margin">
        {#if loading}
            <div class="w3-center w3-container">
                <i class="fas fa-spinner fa-spin fa-5x w3-margin" />
                <p class="w3-xlarge">Uploading...</p>
            </div>
        {:else}
            <form class="w3-container" on:submit|preventDefault={newPost}>
                {#if error}
                    <ErrorAlert message={error} />
                {/if}
                <div class="w3-section">
                    {#if file}
                        <p>Chosen image: {file.name}</p>
                    {:else}
                        <button
                            type="button"
                            on:click={chooseFile}
                            class="w3-button w3-white w3-border">Choose image</button>
                    {/if}
                </div>
                <div class="w3-section">
                    <label for="content">Post content</label>
                    <textarea
                        id="content"
                        rows="5"
                        bind:value={content}
                        class="w3-input w3-border" />
                </div>
                <div class="w3-section">
                    <button
                        type="submit"
                        class="w3-button w3-blue w3-border w3-border-blue w3-hover-blue"
                        style="width: 100%">Post</button>
                </div>
            </form>
        {/if}
    </div>
{/if}
Enter fullscreen mode Exit fullscreen mode

Now, all we need to do, is upload this image, and create a new post. Edit the newPost function in newPost.svelte

<!-- src/routes/newPost.svelte -->
<script lang="ts">
    // ...

    function newPost() {
        if (!content || !content.trim()) {
            error = "Enter some content";
            return;
        }
        if (!file) {
            error = "Please choose a file";
            return;
        }
        if (file.type.split("/")[0] !== "image") {
            error = "Please choose an image";
            return;
        }
        content = content.trim();

        let fd = new FormData();
        fd.append("files", file);

        loading = true;

        // uploading file
        axios
            .post<ImageType[]>(apiUrl + "/upload", fd, {
                headers: {
                    "Content-Type": "multipart/formdata",
                    Authorization: "Bearer " + getToken(),
                },
            })
            .then(({ data }) => {
                const imageId: number = data[0].id;

                // creating the post itself
                axios
                    .post<Post>(
                        apiUrl + "/posts",
                        {
                            image: imageId,
                            user: getUserId(),
                            content,
                        },
                        {
                            headers: {
                                Authorization: "Bearer " + getToken(),
                            },
                        }
                    )
                    .then(({ data }) => {
                        window.location.href = `/@${data.user.username}/${data.id}`;
                    })
                    .catch((err) => {
                        if (err.response) {
                            if (
                                err.response.status === 401 ||
                                err.response.status === 400
                            )
                                window.location.href =
                                    "/auth?action=login&next=" +
                                    window.location.pathname;
                            else {
                                error = "";
                                for (let message of err.response.data.message[0]
                                    .messages) {
                                    error += `${message.message}\n`;
                                }
                            }
                        } else error = err;
                    });
            })
            .catch((err) => {
                if (err.response) {
                    if (
                        err.response.status === 401 ||
                        err.response.status === 400
                    )
                        window.location.href =
                            "/auth?action=login&next=" +
                            window.location.pathname;
                    else {
                        error = "";
                        for (let message of err.response.data.message[0]
                            .messages) {
                            error += `${message.message}\n`;
                        }
                    }
                } else error = err;
            });
    }
</script>

<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Remember to import whenever required!

Demo

And that's it! We're done here, well almost. This is good, but we can make it more secure. In the fourth, and final part, I'll show you how to deploy both strapi and our frontend on Heroku and Vercel respectively. Let's look at a demo:

Conclusion

Strapi was really fun to work with, but I still do miss my custom made backend. One thing I think that Strapi is missing, is the ability to get the user info from a JWT out of the box, i.e. without needing to edit its API. You can see that the way we're getting a user id is a very insecure way and not meant for production. I'll show you how you can secure it in the 4th part. Here's the 4th part!

Top comments (4)

Collapse
 
timtrademark profile image
TimTrademark

Great article again! Can't wait to see the next part.

Collapse
 
arnu515 profile image
arnu515

Thank you so much! But, the next part will take a bit of time.

Collapse
 
timtrademark profile image
TimTrademark

Take your time ;).

Collapse
 
arnu515 profile image
arnu515