DEV Community

Cover image for Introducing Almost Netflix: Netflix clone built with Vue and Appwrite
Matej Bačo for Appwrite

Posted on

Introducing Almost Netflix: Netflix clone built with Vue and Appwrite

Welcome to the second post in our Almost Netflix series! We'll be building upon the project setup from yesterday and build a Web frontend for our Netflix Clone! In this post, we will take a closer look at building the clone using VueJS. In the subsequent posts of this series, we'll be building frontends for other platforms like Flutter, iOS and Android!

This one's all about the Web, so let's get started!

Cover image

It would be impossible to write every piece of code in this article 😬 You will read about all essential concepts, components, and communication with Appwrite. Still, if you want to check out every corner of our Almost Netflix web application, you can check out the GitHub Source Code that holds the whole application.

I decided to host the project on Vercel! You can check out the preview of Netflix Clone live demo.

🤔 What is Appwrite?
Appwrite is an open source backend-as-a-service that abstracts all the complexity involved in building a modern application by providing you with a set of REST APIs for your core backend needs. Appwrite handles user authentication and authorization, databases, file storage, cloud functions, webhooks, and much more! If anything is missing, you can extend Appwrite using your favorite backend language.

📃 Requirements

Before we begin, we should have the Appwrite instance up and running, with the Almost Netflix project set up. If you haven't setup the project yet, you can refer to our previous blog post.

To build Almost Netflix, we will use Vue.js because of its decent simplicity and forced structure. I believe reading Vue components is straightforward, and any web developer can understand what the code is trying to achieve.

To manage routing, importing, and folder structure, we will stick to NuxtJS, an intuitive Vue framework.

Last but not least, we will use Tailwind CSS to style the components. Tailwind CSS makes it a bit harder to read HTML code but allows speedy prototyping, allowing us to recreate the Netflix UI in a blink of an eye.

No more, I promise! If you don't know some technologies used in this project, this might be the best moment to continue the article to start learning them. All in all, we are developers, and we need to learn every day 😎 Fun fact, I learned NuxtJS with this project.

🛠️ Create Nuxt project

Thanks to fantastic Tailwind CSS documentation, we can visit their Install Tailwind CSS with Nuxt.js docs that will take us step-by-step creating the NuxtJS project and adding Tailwind CSS.

Once we have the project set up, we remove all files from the components and pages folders. These contain templates to get us started, but we won't need that 😏 To see our setup working, let's create file pages/index.vue and put simple HTML in it:

<template>
  <h1 class="text-blue-500 text-4xl">
    Almost Netflix 🎬
  </h1>
</template>
Enter fullscreen mode Exit fullscreen mode

Make sure the npm run dev is still running in the background. We can visit http://localhost:3000/ and see our big blue title if everything works well.

Let's customize our project a little by using custom fonts. I decided to use Inter font as it's pretty close to Netflix one. Thanks to Google Fonts, we can do tiny changes to our assets/css/main.css to update all fonts on our website:

@tailwind base;
@tailwind components;
@tailwind utilities;

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

* {
  font-family: 'Inter', sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

Lastly, let's prepare all assets in our project by copying them from static folder on GitHub. All we need to do is download them and place them into our static folder. This will ensure we have all logos, icons, and backgrounds ready to be used in HTML later.

Great, the project is ready! Let's continue by preparing Appwrite services to communicate with the Appwrite server.

🤖 Appwrite Service

We create the file services/appwrite.ts and prepare a few functions to get the hang of it. We will use this file for direct communication with Appwrite SDK. By doing this, we separate server-communication logic from the rest of the application logic, resulting in more readable code.

Let's start by preparing the Appwrite SDK variable:

import { Appwrite, Models, Query } from "appwrite";

const sdk = new Appwrite();
sdk
    .setEndpoint("http://localhost/v1")
    .setProject("almostNetflix");
Enter fullscreen mode Exit fullscreen mode

Make sure to use your own endpoint and project ID. Please don't ask what happened to almostNetfix1. I am not proud of that 😅

Since we are using TypeScript, let's also add definitions, so we can use them later to describe what data we are getting from Appwrite:

export type AppwriteMovie = {
  name: string,
  description: string,
  durationMinutes: number,
  thumbnailImageId: string,
  releaseDate: number,
  ageRestriction: string,

  relationId?: string
} & Models.Document;

export type AppwriteWatchlist = {
  movieId: string,
  userId: string
} & Models.Document;
Enter fullscreen mode Exit fullscreen mode

Now that we have types and SDK ready let's create and export AppwriteService itself. Inside, let's also add a few functions for authentication, so we have a starting point for upcoming authentication components:

export const AppwriteService = {
    // Register new user into Appwrite
    async register(name: string, email: string, password: string): Promise<void> {
        await sdk.account.create("unique()", email, password, name);
    },

    // Login existing user into his account
    async login(email: string, password: string): Promise<void> {
        await sdk.account.createSession(email, password);
    },

    // Logout from server removing the session on backend
    async logout(): Promise<boolean> {
        try {
            await sdk.account.deleteSession("current");
            return true;
        } catch (err) {
            // If error occured, we should not redirect to login page
            return false;
        }
    },

    // Figure out if user is logged in or not
    async getAuthStatus(): Promise<boolean> {
        try {
            await sdk.account.get();
            return true;
        } catch (err) {
            // If there is error, user is not logged in
            return false;
        }
    },  
};
Enter fullscreen mode Exit fullscreen mode

Perfect! Now we have our AppwriteService ready to be used by the Vue application and a few authentication functions already set up. We can revisit this file any time in the future and add more functions to make sure this file is our "gateway" to Appwrite.

With AppwriteService ready for authentication, we should implement Vue components for that, right?

🔐 Authentication

Before we start, let's update our pages/index.vue to have a welcome message and buttons to redirect a visitor to login and register pages. Since I don't want to make this article about HTML and Tailwind CSS, you can check out the Index file on GitHub.

Index page

We can copy pages/login.vue from login file and pages/register.vue from register file exactly the same way, although we will take a closer look at these two.

When copying index, login, and register files, middleware is already configured on them. You might need to temporarily remove those for pages to load correctly. We will be creating middlewares in upcoming sections.

In pages/login.vue, we create a form and listen to its submission:

<form @submit.prevent="onLogin()">
    <input v-model="email" type="email" />
    <input v-model="pass" type="password"/>
    <button type="submit">Sign In</button>
</form>
Enter fullscreen mode Exit fullscreen mode

We then create the onLogin method where we talk to AppwriteService and redirect to the application after successful login:

export default Vue.extend({
  data: () => {
    return {
      email: '',
      pass: '',
    }
  },
  methods: {
    async onLogin() {
      try {
        await AppwriteService.login(this.email, this.pass)
        this.$router.push('/app')
      } catch (err: any) {
        alert(err.message)
      }
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

You can also notice we use data for in-component state management, and thanks to the v-model Vue attribute, the value from the input is automatically stored in the variable.

Login page

Looking at pages/register.vue, we do the same process with different values. The only main difference is in our onRegister function (alternative to onLogin), which also validates if passwords match and if the user agrees with terms:

export default Vue.extend({
    data: () => {
        return {
            name: '',
            email: '',
            pass: '',
            passAgain: '',
            agreeTerms: false,
        }
    },
    methods: {
        async onRegister() {
            if (this.pass !== this.passAgain) {
                alert('Passwords need to match.')
                return
            }

            if (!this.agreeTerms) {
                alert('You have to agree to our terms.')
                return
            }

            try {
                await AppwriteService.register(this.name, this.email, this.pass)
                await AppwriteService.login(this.email, this.pass)
                this.$router.push('/app')
            } catch (err: any) {
                alert(err.message)
            }
        },
    },
})
Enter fullscreen mode Exit fullscreen mode

Notice that right after we register, we also login the user with the same credentials. This allows us to redirect the user directly to the application instead of asking them to login.

Register page

To finish the login process, we need to create pages/app/index.vue, the first page the user sees when they log in. Actually, let me show you a trick here...

When a user logs in, I want them to see a list of all movies, but I also want to URL to be app/movies. This will allow me to make pages like app/watchlist, app/profiles or app/tv-shows in the future.

We create a really simple pages/app/index.vue component to achieve this. The only thing this component will do is redirect to the new path app/movies:

<template></template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  middleware: [
    function ({ redirect }) {
      redirect('/app/movies')
    },
  ],
})
</script>
Enter fullscreen mode Exit fullscreen mode

Now we create a new file called pages/app/movies.vue and put movies logic in there. In summary, after successful login, you will be redirected to /app, but you won't even see this page because you will be redirected to /app/movies straight away.

For now, let's put a simple greeting text into our pages/app/movies.vue file:

<template>
  <h1>Welcome logged in user 👋</h1>
</template>
Enter fullscreen mode Exit fullscreen mode

We are done with authentication! Oh, wait... As I am playing with the website, I noticed I can manually change the URL in by browser to /app and the application allows me to see movies page 😬 Let's look how we can use middleware to force redirects on specific pages depending on if user is logged in or not.

Authentication middlewares

Middleware can be used to restrict a user from visiting a specific pages. In our scenario, we don't want to allow the user to visit the movies page if they are not logged in. First, let's create middleware/only-authenticated.ts with a simple logic that checks current user status and redirects to login if the user is not logged in:

import { Middleware } from "@nuxt/types";
import { AppwriteService } from "../services/appwrite";

const middleware: Middleware = async ({ redirect }) => {
    const isLoggedIn = await AppwriteService.getAuthStatus();

    if (isLoggedIn) {
        // OK
    } else {
        return redirect("/login");
    }
}

export default middleware;
Enter fullscreen mode Exit fullscreen mode

Thanks to this middleware, the user will be allowed to visit the route if they are logged in but will be redirected if they are not. But what route? 🤔

To use this middleware, we need to apply it to a specific page. Since we don't want to allow a user to visit the movies page, we update pages/app/movies.ts:

<template>
    <h1>Welcome logged in user 👋</h1>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  middleware: 'only-authenticated',
})
</script>
Enter fullscreen mode Exit fullscreen mode

Just like that ✨ we protected our page, and we only allow logged-in users to view our movies page. Real quick, let's do the exact opposite for the rest of the pages we currently have - let's redirect the user to the application if they are already logged in. We do this to prevent the user from getting to the login page if they are already logged in.

To achieve this, we create one more middleware in middleware/only-unauthenticated.ts:

import { Middleware } from "@nuxt/types";
import { AppwriteService } from "../services/appwrite";

const middleware: Middleware = async ({ redirect }) => {
    const isLoggedIn = await AppwriteService.getAuthStatus();

    if (isLoggedIn) {
        return redirect("/app");
    } else {
        // OK
    }
}

export default middleware;
Enter fullscreen mode Exit fullscreen mode

Notice, we did the exact opposite in this component. If a user is not logged-in, it's OK, but we forcefully redirect to the application page if they are.

Now, let's add this only-unauthenticated middleware to all the 3 pages pages/index.vue, pages/login.vue and pages/register.vue.

Let's try it! If we are logged in and try to visit /login, we will jump back to the movies page. Great! We have successfully implemented middleware to protect our application's specific pages from unauthenticated users.

🏗 Application layout

In every application, some parts repeat on all pages. In most cases, it's header and footer, but it could also be a hero section or live chat bubble. To prevent repeating this part of code, we can create a layout out of it and use layout on our pages, similar to how we used middleware. First, let's create a simple layout and use it on our movies page. To do that, we create layouts/app.vue:

<template>
    <h1>Header</h1>
    <hr>
    <Nuxt />
    <hr>
    <h1>Footer</h1>
</template>
Enter fullscreen mode Exit fullscreen mode

We used a special HTML tag <Nuxt />, which means, if a page is using this layout, the page's content will be placed exactly where we put our <Nuxt /> tag. This is really handy if we want to place a page in between the header and footer.

To use our app layout, we mention it on our movie page. We simply update pages/app/movies.vue:

<!-- ... -->

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  layout: 'app',
    // ...
})
</script>
Enter fullscreen mode Exit fullscreen mode

We can now see our header and footer wrap our movies page. Awesome! Let's create an actual Netflix layout, shall we?

First, let's update our AppwriteService since we will need to show the user's profile picture in the header. The header should also include a trending movie if we are on the landing page. To begin, let's create a function that gives us the user's profile picture:

export const AppwriteService = {
    // ...

    // Generate profile photo from initials
    async getProfilePhoto(): Promise<URL> {
        let name = "Anonymous";

        try {
            const account = await sdk.account.get();

            if (account.name) {
                // If we have name, use that for initials
                name = account.name;
            } else {
                // If not, use email. That is 100% available always
                name = account.email;
            }
        } catch (err) {
            // Means we don't have account, fallback to anonymous image
        }

        // Generate URL from previously picked keyword (name)
        return sdk.avatars.getInitials(name, 50, 50);
    } 
};
Enter fullscreen mode Exit fullscreen mode

We should also prepare a function to preview the cover image of the movie. We will need a separate function for this because this main trending movie is covering the whole website with one huge image:

export const AppwriteService = {
    // ...

    // Same as above. Generates URL, setting some limits on size and format
    getMainThumbnail(imageId: string): URL {
        return sdk.storage.getFilePreview(imageId, 2000, undefined, "top", undefined, undefined, undefined, undefined, undefined, undefined, undefined, "webp");
    }
};
Enter fullscreen mode Exit fullscreen mode

Finally, let's implement a method to get featured movies from our database:

export const AppwriteService = {
    // ...

    // Simple query to get the most trading movie
    async getMainMovie(): Promise<AppwriteMovie> {
        const response = await sdk.database.listDocuments<AppwriteMovie>("movies", [], 1, undefined, undefined, undefined, ["trendingIndex"], ["DESC"]);
        return response.documents[0];
    }
};
Enter fullscreen mode Exit fullscreen mode

With all of these methods ready, we can start using them in our layout. Let's visit app layout file on GitHub and copy its content to our page. Our layout looks lovely, and we have already got our first movie! This is starting to look almost like Netflix 🎉

🎬 Movies page

We need to show rows of movies for different categories on our movies page, such as Popular this week or New releases. Before implementing this into our page, we will need methods to fetch data from Appwrite.

First of all, let's create categories configuration in one variable inside of our AppwriteService, that we can re-use later:

export type AppwriteCategory = {
  title: string;
  queries: string[];
  orderAttributes: string[];
  orderTypes: string[];
  collectionName?: string;
}

export const AppwriteMovieCategories: AppwriteCategory[] = [
  {

    title: "Popular this week",
    queries: [],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {

    title: "Only on Almost Netflix",
    queries: [
      Query.equal("isOriginal", true)
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {

    title: "New releases",
    queries: [
      Query.greaterEqual('releaseDate', 2018),
    ],
    orderAttributes: ["releaseDate"],
    orderTypes: ["DESC"]
  },
  {

    title: "Movies longer than 2 hours",
    queries: [
      Query.greaterEqual('durationMinutes', 120)
    ],
    orderAttributes: ["durationMinutes"],
    orderTypes: ["DESC"]
  },
  {

    title: "Love is in the air",
    queries: [
      Query.search('genres', "Romance")
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {

    title: "Animated worlds",
    queries: [
      Query.search('genres', "Animation")
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {

    title: "It's getting scarry",
    queries: [
      Query.search('genres', "Horror")
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {

    title: "Sci-Fi awaits...",
    queries: [
      Query.search('genres', "Science Fiction")
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {

    title: "Anime?",
    queries: [
      Query.search('tags', "anime")
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
  {
    title: "Thriller!",
    queries: [
      Query.search('genres', "Thriller")
    ],
    orderAttributes: ["trendingIndex"],
    orderTypes: ["DESC"]
  },
];

export const AppwriteService = {
    // ...
};
Enter fullscreen mode Exit fullscreen mode

We just configured all the different categories we want to show on our homepage, each having a title, queries, and sorting configuration. Let's also prepare a function to get a list of movies where input is one of these categories:

export const AppwriteService = {
    // ...

    // List movies. Most important function
    async getMovies(perPage: number, category: AppwriteCategory, cursorDirection: 'before' | 'after' = 'after', cursor: string | undefined = undefined): Promise<{
        documents: AppwriteMovie[],
        hasNext: boolean;
    }> {
        // Get queries from category configuration. Used so this function is generic and can be easily re-used
        const queries = category.queries;

        const collectionName = category.collectionName ? category.collectionName : "movies";
        let documents = [];

        // Fetch data with configuration from category
        // Limit increased +1 on purpose so we know if there is next page
        let response: Models.DocumentList<any> = await sdk.database.listDocuments<AppwriteMovie | AppwriteWatchlist>(collectionName, queries, perPage + 1, undefined, cursor, cursorDirection, category.orderAttributes, category.orderTypes);

        // Create clone of documents we got, but depeding on cursor direction, remove additional document we fetched by setting limit to +1
        if (cursorDirection === "after") {
            documents.push(...response.documents.filter((_d, dIndex) => dIndex < perPage));
        } else {
            documents.push(...response.documents.filter((_d, dIndex) => dIndex > 0 || response.documents.length === perPage));
        }

        if (category.collectionName) {
            const nestedResponse = await sdk.database.listDocuments<AppwriteMovie>("movies", [
                Query.equal("$id", documents.map((d) => d.movieId))
            ], documents.length);

            documents = nestedResponse.documents.map((d) => {
                return {
                    ...d,
                    relationId: response.documents.find((d2) => d2.movieId === d.$id).$id
                }
            }).sort((a, b) => {
                const aIndex = response.documents.findIndex((d) => d.movieId === a.$id);
                const bIndex = response.documents.findIndex((d) => d.movieId === b.$id);

                return aIndex < bIndex ? -1 : 1;
            })
        }

        // Return documents, but also figure out if there was this +1 document we requested. If yes, there is next page. If not, there is not
        return {
            documents: documents as AppwriteMovie[],
            hasNext: response.documents.length === perPage + 1
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

Notice that we accept per-page limit and cursor into our function to allow proper pagination. We also return the hasNext boolean, which says if the next page exists or not. All of that will come into place once we start implementing the movies page, as there we will need this pagination system.

Before we leave our AppwriteService, we implement one more function to allow us to preview movie covers. This one will be similar to the one we created for the trending movie, but we can adjust the configuration to a smaller width, as this will not cover as much of the screen as the trending movie does:

export const AppwriteService = {
    // ...

    // Generate URL that will resize image to 500px from original potemtially 4k image
    // Also, use webp format for better performance
    getThumbnail(imageId: string): URL {
        return sdk.storage.getFilePreview(imageId, 500, undefined, "top", undefined, undefined, undefined, undefined, undefined, undefined, undefined, "webp");
    }
};
Enter fullscreen mode Exit fullscreen mode

Yey, ApprwiteService is ready! 😎 Let's update our movies page in pages/app/movies.vue, and let's look through app categories, showing movie list for each of them:

<template>
  <div>
    <div class="flex flex-col space-y-20">
      <movie-list
        v-for="category in categories"
        :key="category.title"
        :category="category"
      />
    </div>
  </div>
</template>

<script lang="ts">
    import Vue from 'vue'
    import {
        AppwriteMovieCategories,
    } from '~/services/appwrite'

    export default Vue.extend({
        data: () => {
            return {
                categories: AppwriteMovieCategories,
            }
        },
    })
</script>
Enter fullscreen mode Exit fullscreen mode

Now, the complex part... We need to create this <movie-list> we just used. Such a component should use our AppwriteService to get a list of movies inside the category and manage pagination to allow us to scroll through the category.

First, let's create the component and write HTML that will loop through a list of movies:

<template>
  <div>
    <h1 class="text-4xl text-zinc-200">{{ category.title }}</h1>

    <div
      v-if="movies.length > 0"
      class="relative grid grid-cols-2 gap-4 mt-6  sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
    >
      <Movie
        v-for="(movie, index) in movies"

        :isPaginationEnabled="true"
        :onPageChange="onPageChange"
        :moviesLength="movies.length"
        :isLoading="isLoading"
        :isCursorAllowed="isCursorAllowed"
        class="col-span-1"
        :key="movie.$id"
        :appwrite-id="movie.$id"
        :movie="movie"
        :index="index"
      />
    </div>

    <div v-if="movies.length <= 0" class="relative mt-6 text-zinc-500">
      <p>This list is empty at the moment...</p>
    </div>
  </div>
</template>

<script lang="ts">
    import Vue from 'vue'

    export default Vue.extend({
        props: ['category'],
    });
</script>
Enter fullscreen mode Exit fullscreen mode

Now, let's implement logic to prepare this movies array:

export default Vue.extend({
    // ...

    data: () => {
        const width = window.innerWidth
        let perPage: number

        // Depending on the device size, use different page size
        if (width < 640) {
            perPage = 2
        } else if (width < 768) {
            perPage = 3
        } else if (width < 1024) {
            perPage = 4
        } else if (width < 1280) {
            perPage = 5
        } else {
            perPage = 6
        }

        return {
            perPage,
            isLoading: true,
            isBeforeAllowed: false,
            isAfterAllowed: true,
            movies: [] as AppwriteMovie[],

            lastCursor: undefined as undefined | string,
            lastDirection: undefined as undefined | 'before' | 'after',
        }
    },

    async created() {
        // When component loads, fetch movie list with defaults for pagination (no cursor)
        const data = await AppwriteService.getMovies(
            this.perPage,
            this.$props.category
        )

        // Store fetched data into component variables
        this.movies = data.documents
        this.isLoading = false
        this.isAfterAllowed = data.hasNext
    },
});
Enter fullscreen mode Exit fullscreen mode

Finally, let's add methods that will allow us to paginate over the category:

export default Vue.extend({
    // ...

    isCursorAllowed(index: number) {
        // Simply use variables we fill during fetching data from API
        // Depending on index (direction) we want to return different variables
        if (index === 0) {
            return this.isBeforeAllowed
        }

        if (index === this.movies.length - 1) {
            return this.isAfterAllowed
        }
    },

    async onPageChange(direction: 'before' | 'after') {
        // Show spinners instead of arrows
        this.isLoading = true

        // Use relation ID if provided
        const lastRelationId =
            direction === 'before'
                ? this.movies[0].relationId
                : this.movies[this.movies.length - 1].relationId

        // Depending on direction, get ID of last document we have
        let lastId = lastRelationId
            ? lastRelationId
            : direction === 'before'
                ? this.movies[0].$id
                : this.movies[this.movies.length - 1].$id

        // Fetch new list of movies using direction and last document ID
        const newMovies = await AppwriteService.getMovies(
            this.perPage,
            this.$props.category,
            direction,
            lastId
        )

        // Fetch status if movie is on My List or not
        await this.LOAD_FAVOURITE(newMovies.documents.map((d) => d.$id))

        // Now lets figure out if we have previous and next page...
        // Let's start with saying we have them both, then we will set it to false if we are sure there isnt any
        // By setting default to true, we never hide it when we shouldnt.. Worst case scenario, we show it when we shoulding, resulsing in you seing the arrow, but taking no effect and then dissapearing
        this.isBeforeAllowed = true
        this.isAfterAllowed = true

        // If we dont get any documents, it means we got to edge-case when we thought there is next/previous page, but there isnt
        if (newMovies.documents.length === 0) {
            // Depending on direction, set that arrow to disabled
            if (direction === 'before') {
                this.isBeforeAllowed = false
            } else {
                this.isAfterAllowed = false
            }
        } else {
            // If we got some documents, store them to component variable and keep both arrows enabled
            this.movies = newMovies.documents
        }

        // If our Appwrite service says there isn' next page, then...
        if (!newMovies.hasNext) {
            // Depnding on direction, set that specific direction to disabled
            if (direction === 'before') {
                this.isBeforeAllowed = false
            } else {
                this.isAfterAllowed = false
            }
        }

        // Store cursor and direction if I ever need to refresh the current page
        this.lastDirection = direction
        this.lastCursor = lastId

        // Hide spinners, show arrows again
        this.isLoading = false
    },
});
Enter fullscreen mode Exit fullscreen mode

You can find the whole component in movie list component file.

Woah, that was a ride 🥵 Let's finish off by creating <Movie> component in components/Movie.vue to render one specific movie. We can use movie component file as a reference.

Perfect, we have our movie lists ready! We are missing one last feature to allow users to click a movie to see details. To get that working, you can copy movie modal file, filter modal file, and modal store file. Since these files are only related to HTML, Tailwind CSS, and Vue state management, it would be off-topic to go through them one by one. Don't worry, nothing too interesting is happening in there 😅

Modal

Filter modal

The only missing piece of our puzzle is the watchlist. Let's implement it!

🔖 Watchlist page

As always, let's start by preparing backend communication in our AppwriteService. We will need two functions to update our watchlist - one for removing, one for adding new movies to my watchlist:

export const AppwriteService = {
    // ...

    async addToMyList(movieId: string): Promise<boolean> {
        try {
            const { $id: userId } = await sdk.account.get();

            await sdk.database.createDocument("watchlists", "unique()", {
                userId,
                movieId,
                createdAt: Math.round(Date.now() / 1000)
            });
            return true;
        } catch (err: any) {
            alert(err.message);
            return false;
        }
    },

    async deleteFromMyList(movieId: string): Promise<boolean> {
        try {
            const { $id: userId } = await sdk.account.get();

            const watchlistResponse = await sdk.database.listDocuments<AppwriteWatchlist>("watchlists", [
                Query.equal("userId", userId),
                Query.equal("movieId", movieId)
            ], 1);

            const watchlistId = watchlistResponse.documents[0].$id;

            await sdk.database.deleteDocument("watchlists", watchlistId);
            return true;
        } catch (err: any) {
            alert(err.message);
            return false;
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

To achieve proper state management in the future, we need one more function, so when we have a list of movies, we can figure out which ones are already on the user's watchlist:

export const AppwriteService = {
    // ...

    async getOnlyMyList(movieIds: string[]): Promise<string[]> {
        const { $id: userId } = await sdk.account.get();

        const watchlistResponse = await sdk.database.listDocuments<AppwriteWatchlist>("watchlists", [
            Query.equal("userId", userId),
            Query.equal("movieId", movieIds)
        ], movieIds.length);

        return watchlistResponse.documents.map((d) => d.movieId);
    }
};
Enter fullscreen mode Exit fullscreen mode

Now, let's create a page /app/my-list where people can see their watchlist. To do that, we create /pages/app/my-list.vue file. Thankfully, we can re-use our category logic to render a list of movies properly:

<template>
  <div>
      <movie-list :category="category" />
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { AppwriteCategory } from '../../services/appwrite'

export default Vue.extend({
  middleware: 'only-authenticated',
  layout: 'app',
  data() {
    const category: AppwriteCategory = {
      collectionName: 'watchlists',
      title: 'Movies in My List',
      queries: [],
      orderAttributes: [],
      orderTypes: [],
    }

    return {
      category,
    }
  },
})
</script>
Enter fullscreen mode Exit fullscreen mode

Then, let's setup state management which will be the source of truth for the whole application about whether the movie is already on the watchlist or not. To do that, we can copy the my list store file from GitHub.

Finally, we define a component that will serve as a button to add/remove the movie from the watchlist. We can find this component in watchlist component file.

Watchlist button component

Believe it or not, the Netflix clone is ready! 🥳 We should host it so anyone can see it, right?

🚀 Deployment

We will deploy our Nuxt project on Vercel. I fell in love with this platform thanks to the ease of deployment, and the fact this platform is free for pretty-much all of your side projects.

After creating a repository for our project in GitHub, we create a new project on Vercel pointing to this repository. We configure the build process to use npm run generate for building, dist as output folder, and npm install as installation command. We wait for Vercel to finish the build, and we will be presented with a custom Vercel subdomain that contains our website.

When we visit it, we notice we start getting network errors 😬 We look at the console and notice a CORS error from Appwrite... But why? 🤔

So far, we have only been developing a website locally, meaning we used hostname localhost. Thankfully, Appwrite allows all communication from localhost to allow ease of development. Since we are now on Vercel hostname, Appwrite no longer trusts it, and we need to configure this as a production platform. To do it, we visit the Appwrite Console website and enter our project. If we scroll down a little in our dashboard, we will notice the Platforms section. In here, we need to add a new web platform with the hostname Vercel assigned to you.

Appwrite platform modal

Appwrite platform list

After adding the platform, Appwrite now trusts our deployment on Vercel, and we can start using it! 🥳 Believe it or not, we just created a Netflix clone using Appwrite ( Almost ) .

👨‍🎓 Conclusion

We have successfully cloned Netflix movies using Appwrite. As you can see, your imagination is your limit with Appwrite! To become part of the Appwrite community, you can join our Discord community server. I can't wait to see you around and look at what you build with Appwrite 🤩

This project is not over! 😎 With upcoming Appwrite releases, we will be improving this Netflix clone and adding more features. You can get ready for video streaming, custom changes to the backend, and much more!

Here are some handy links and resources:

🔗 Learn more

You can use the following resources to learn more and get help regarding Appwrite and its services

Top comments (2)

Collapse
 
stnguyen90 profile image
Steven • Edited
Collapse
 
ankush21 profile image
Ankush Agrawal

Thank you for letting me know about the vue.js and iOS app development
theonetechnologies.com/hire-iphone...