Introducing Almost Netflix: Netflix clone built with Vue and Appwrite
Tags: opensource, javascript, webdev, showdev
Published: 2022-01-19 13:34:35

Welcome to the second post in our Almost Netflix series! We'll be building upon the [project setup from yesterday](https://dev.to/appwrite/did-we-just-build-a-netflix-clone-with-appwrite-28ok) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rxtoesph4ojozd9wcxwy.png) 

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](https://github.com/appwrite/demo-almost-netflix-for-web) that holds the whole application.

I decided to host the project on Vercel! You can check out the preview of [Netflix Clone live demo](https://almost-netflix.vercel.app/app/movies).


> **🤔 What is Appwrite?** 
[Appwrite](https://appwrite.io/) 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](https://dev.to/appwrite/did-we-just-build-a-netflix-clone-with-appwrite-28ok).

To build Almost Netflix, we will use [Vue.js](https://vuejs.org/) 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](https://nuxtjs.org/), an intuitive Vue framework.

Last but not least, we will use [Tailwind CSS](https://tailwindcss.com/) 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](https://tailwindcss.com/docs/guides/nuxtjs) 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:

```html
<template>
  <h1 class="text-blue-500 text-4xl">
    Almost Netflix 🎬
  </h1>
</template>
```

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](https://fonts.google.com/specimen/Inter?query=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:
```css
@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;
}
```

Lastly, let's prepare all assets in our project by copying them from [static folder](https://github.com/appwrite/demo-almost-netflix-for-web/tree/master/static) 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:

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

const sdk = new Appwrite();
sdk
    .setEndpoint("http://localhost/v1")
    .setProject("almostNetflix");
```

> 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](https://www.typescriptlang.org/), let's also add definitions, so we can use them later to describe what data we are getting from Appwrite:

```typescript
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;
```

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:

```typescript
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;
        }
    },  
};
```

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](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/pages/index.vue) on GitHub.

![Index page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jt1ayfr5r3qu8heqi1kl.png)

We can copy `pages/login.vue` from [login file](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/pages/login.vue) and `pages/register.vue` from [register file](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/pages/register.vue) 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:

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

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

```typescript
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)
      }
    },
  },
})
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u1b67ucg7769pk0zllx5.png)

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:

```typescript
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)
            }
        },
    },
})
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4twebf913s38cudu8mb8.png)

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

```html
<template></template>

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

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

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:

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

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:

```typescript
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;
```

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

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

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

export default Vue.extend({
  middleware: 'only-authenticated',
})
</script>
```
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`:

```typescript
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;
```

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

```html
<template>
    <h1>Header</h1>
    <hr>
    <Nuxt />
    <hr>
    <h1>Footer</h1>
</template>
```

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

```html
<!-- ... -->

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

export default Vue.extend({
  layout: 'app',
    // ...
})
</script>
```

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:

```typescript
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);
    } 
};
```

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:

```typescript
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");
    }
};
```

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

```typescript
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];
    }
};
```

With all of these methods ready, we can start using them in our layout. Let's visit [app layout file](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/layouts/app.vue) 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:

```typescript
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 = {
    // ...
};
```

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:

```typescript
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
        };
    }
};
```

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:

```typescript
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");
    }
};
```

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:

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

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:

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

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

```typescript
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
    },
});
```

Finally, let's add methods that will allow us to paginate over the category:
```typescript
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
    },
});
```

> You can find the whole component in [movie list component file](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/components/MovieList.vue).

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](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/components/Movie.vue) 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](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/components/MovieModal.vue), [filter modal file](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/components/FilterModal.vue), and [modal store file](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/store/modal.ts). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zpyt0f7koeyyt84fzx22.png)

![Filter modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e0i0tw0ysb72533j7sw2.png)

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:

```typescript
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;
        }
    }
};
```

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:

```typescript
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);
    }
};
```

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:

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

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](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/store/myList.ts) 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](https://github.com/appwrite/demo-almost-netflix-for-web/blob/master/components/AddToMyList.vue).

![Watchlist button component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i5rtfsq95orf1fp0s20r.png)

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](https://vercel.com/). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xpmwyvd9jc7nfqlx0jws.png)

![Appwrite platform list](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ayq15o6l1zdso7rxuqm0.png)

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](https://appwrite.io/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:

- [Almost Netflix for Web](https://github.com/appwrite/demo-almost-netflix-for-web)
- [Appwrite Github](https://github.com/appwrite)
- [Appwrite Documentation](https://appwrite.io/docs)

## 🔗 Learn more

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

- 🚀 [Appwrite Github](https://github.com/appwrite)
- 📜 [Appwrite Docs](https://appwrite.io/docs)
- 💬 [Discord Community](https://appwrite.io/discord)