Skip to content

Apestein/nextflix

Repository files navigation

Project using bleeding-edge stack. Drizzle ORM + Neon postgres + Clerk auth + Shadcn/ui + everything new in Next.js 13 (server components, server actions, streaming ui, parallel routes, intercepting routes). Now fully edge runtime deployed.

Full Tech Stack

  • Next.js 13
  • CreateT3App Bootrapped
  • Neon (postgres)
  • Drizzle ORM
  • Tailwind + Shadcn/ui
  • Clerk
  • Lucide Icons
  • Zod Validation
  • Stripe

Project Description

Netflix clone, project inspired by @sadmann17. Bootrapped with CreateT3App. Project uses 100% server actions, zero api endpoints aside for webhooks. next-safe-action library for typesafe server actions. Each account can have up to 4 profiles. Each profiles have it's own avatar and list of saved shows. Feature includes ability to search show catalog, SaaS subscription service with Stripe, optimistic update, and infinite scrolling.

How To Run Locally

Clone repo, install dependencies, and set environment variables inside ".env.example", remember to rename ".env.exmaple" to ".env". Run "npm run dev" -> "npm run stripe:listen"(forward stripe events to local).

Overall Thoughts

Next.js 13 app router overall was a joy to work with and is GREATLY superior to page router. I will say the Next.js app router docs are currently very terrible and you will be left to figure out many things on your own. However, the tools that Next.js 13 gives you are very powerful and things you won't find in any other framework. Server components are underrated and more powerful than you may think. There is an art to interweaving server and client components that is hard to grasp until you get down and dirty with them. Parrallel and intercepting routes are incredibly useful although very buggy. Lucky for you, I've already figured out most of the bugs/tricky bits so just read the "Tricky things" section. Streaming ui and suspense is great. It really makes it easy to handle loading states. This is one of the most impactful things about app dir vs page dir. I saved the most important topic for last. When I first started using server actions I really didn't understand the point. They kind of felt like another way to write api endpoints and just felt like a worst version of tRPC. The Next.js docs will push you to use the server component version of server actions using forms but trust me, don't use server actions with forms. If you do, you are giving up the best feature of server actions which is the tRPC like typesafety. To get the best DX out of server actions, I recommend using next-safe-actions, this lib is a game changer. It made server actions felt just like tRPC and overall was just an amazing DX. I think it's still too early for server actions to replace tRPC but the nice thing is that it requires zero setup. Setting tRPC up for app dir would be a headache right now. Also note, revalidatePath/Tag currently only work with server actions and you will definitely need them.

Thoughts about Clerk

Clerk was amazing to work with in terms of DX. Extremely easy to setup and get rolling. However, there is a major problems that's a deal breakers until they fix it.

  • Clerk causes your entire app to be dyamically rendered. Meaning you can not benefit from things like SSG and ISR. Override with "cache: force-cache" or "revalidate = 0" is not possible. Screenshot (83)

Thoughts about Neon

  • Error prone, not production ready, support is slow to respond, and hard to get help because they don't have Discord. Wouldn't recommend for serious projects. Foreign key contraint is nice to have compared to Planetscale. The biggest pro is the data branching feature is free. On Planetscale you need "Scaler Pro" for this feature. Data branching makes a huge difference for development/debugging. I do miss not having Planetscale's "Slowest queries during the last 24 hours" panel in the dashboard.

Thoughts about Drizzle

Fantastic. Noticeably faster than Prisma. Schema file being in typescript results in superior DX. Their docs are a little lacking though.

Project Setup

CreateT3App comes with some nice things like T3 Env and Typescript-Eslint preconfigured. To bootstrap with CreateT3App, you just need to delete page dir and create app dir. And VERY important, in next.config.mjs you must delete "i18n" property.

Project Structure

Some people like to break everything down into neat little components and organize them into different files. I prefer big files, nothing gets extracted until it gets used in at least 2 different places. If your site is complex you will probably need many different layout. Your root layout.tsx file should contain only the things shared by your entire app. For parts of your site that need different layout use route groups. Screenshot (74)

Tricky Things To Consider (I will be going over things I found tricky or difficult in this section)

1. You will no doubt run into problems with Next.js aggressive caching. To invalidate router cache, you must use RevalidatePath/Tage in server action. However, Revalidate/Tag also causes the current page to refresh. I don't know why Next.js decided to do this but get around this I use this LinkButton component. See the problem below.

scrnli_8_22_2023_12-30-04.PM2.webm

2. When a new user creates an account or signs in with Clerk's oauth I needed to create an account and profile in my database. At first, I was using Clerk's webhook to create them but the problem was users would get redirected to the landing page before the webhook could add the account and profile to the database. As a result, when users first creates the account. The UserButton component that displayed their avatar and profile infomation was missing. To get around this, I had a CustomUserComponent check if the user exist in the database or not (if not, add).

async function CustomeUserButton() {
  const { userId } = auth()
  if (!userId) return
  const existingAccount = await getAccountWithActiveProfile()
  const account = existingAccount ?? (await createAccountAndProfile())
  ...
}

Additionally, you should wrap the component in suspense to not block the UI and prevent unresponsiveness.

<Suspense fallback={<Skeleton className="h-8 w-8" />}>
  <CustomeUserButton />
</Suspense>

3. I had an object that I needed to extract a tuple from to validate with zod. Here is how.

export const createCheckoutSession = authAction(
  z.object({
    stripeProductId: z.string(),
    planName: z.enum(planTuple),
  }),
}

Screenshot (78)

4. Infinite scrolling can be tricky to implement yourself. Typically, I would use React Query/SWR to do this but I wanted to implement it with server actions this time.

// actions/index.ts
export const getMyShowsInfinite = authAction(
  z.object({
    index: z.number().min(0),
    limit: z.number().min(2).max(50),
  }),
  async (input) => {
    const account = await getAccountWithActiveProfile()
    const shows = await db.query.myShows.findMany({
      where: eq(myShows.profileId, account.activeProfileId),
      limit: input.limit + 1,
      offset: input.index * input.limit,
    })
    const hasNextPage = shows.length > input.limit ? true : false
    if (hasNextPage) shows.pop()
    const filteredShows = await getMyShowsFromTmdb(shows)
    return { shows: filteredShows, hasNextPage }
  },
)

Then, I use this modified infinite scroll component that I created. See the implementation here, ignore the stuff about simulated shows. Important thing to understand is inside IntersectionObserver callback function, you must use refs instead of state. That is because of scoping, the callback is only created once and all the variables inside are snapshotted. To get around this you need to use refs. There maybe other ways, I'm just listing what I know.

const observer = new IntersectionObserver((entries) => {
  if (!hasNextPageRef.current) return // <= must use ref, don't use state
})
scrnli_8_22_2023_12-42-31.PM3.webm

5. For Stripe intergration. Reference these 2 repos and mine also of course. Be careful with webhooks, use the Stripe CLI to forward events to your local environment when testing.

6. Optimistic update with server actions can be tricky. Using next-safe-action's useOptimisticAction hook helps here. Here is how I did it.

scrnli_8_22_2023_12-17-36.PM.webm

7. To prevent the search function from firing with every keystroke. Use the use-debounce package. See my implementation here. All data fetching can be done with server component by using router.push()/replace(). Pretty crazy pattern if you ask me🤯.

scrnli_8_22_2023_12-51-37.PM4.webm

8. Very frustrating problem I ran into was the scrollbar causing layout shift. When users navigate from a page with scrollbar to a page without scrollbar there would be an annoying layout shift.

nextflix-demo_Trim.mp4

The solution is to use scrollbar-gutter css property. However, the documentation is terrible and I couldn't get it to work after many hours of debugging. I ended up just googling "scrollbar-gutter not working" and found this stackoverflow answer lol. Basically, I just need to place scrollbar-gutter:stable on the HTML element🤦‍♂️. However this introduced a new bug when using "scrollbar-gutter" with modals, specifically Shadcn/ui & Radix modals. Opening a modal causes layout shift.

scrnli_8_25_2023_5-58-40.PM5.webm

Moreover, since I must set "scrollbar-gutter" on the HTML element. This meant that my whole application will have a gutter (small padding on the right) on every page, we wouldn't want a gutter on our auth pages for example. After some research, here is the solution that I came up with:

//app/layout.tsx
<html
  lang="en"
  suppressHydrationWarning
  className="[&:not(:has([role='dialog'])):has([data-layout='main'])]:[scrollbar-gutter:stable]"
  //Basically, this means only set "scrollbar-gutter:stable" when current page has both element with attribute NOT [role="dialog"] (ie. when modal is NOT open) and element with atrribute [data-layout=main] (ie. when we are in page of "(main)" route group). Wow, who knew CSS could be so powerful🤯
>
  ...
</html>
//app/(main)/layout.tsx
<div
  className="container flex min-h-screen flex-col px-4 md:px-8"
  data-layout="main" //<= add this
>
  <Header />
  {children}
  <Footer />
</div>

Edit: Unfortunately, the solution above introduced some new layout shift. But I think the solution is cool so I will leave it here. This is the new solution, I just force a scrollbar on the (main) route group. Not the perfect solution but it's the best I can up come with.

//app/layout.tsx
<body
  className={cn(
    "bg-neutral-900 text-slate-50 antialiased [&:has([data-layout='main'])]:overflow-y-scroll",
    inter.className,
  )}
>
  ...
</body>

Edit: Now I'm using OverlayScrollbars. I think this is the best solution. See my implementation here.

9. For modal using intercepting route. Follow next.js [official example(https://github.com/vercel-labs/nextgram). You can only use router.back() to close the modal as far as I know. By default when opening the intercepting modal, it will cause page to scroll either all the way up or down. To prevent this, set scroll={false} on Link.

<Link
  href={`/show/${show.id}?mediaType=${
  show.title ? "movie" : "tv"
  }`}
  scroll={false}
  key={show.id}
>

If you have a loading.tsx file for the modal it should go in the @modal folder like this. You can see just how much of different intercepting modal makes vs normal modal by looking at my previous solution. Previous vs New

10. To deploy to the edge, you only need 2 lines of code. Since my database is located in US East, edge can be slower than normal serverless lambda if I don't set a preferredRegion close to my database location. Currently, there is bug with Clerk and Next.js in local development if you're on Windows. Just comment out the edge runtime export when in development, when you deploy to vercel it should be fine.

export const runtime = "edge"
export const preferredRegion = "iad1"

11. Next.js image component is more complicated than you think, check out this video. To optimize, first I request the smallest resolution necessary. TMDB api won't let us request anything smaller than 300w, but ideally it should be 240w since I know that's the maximum size it can be. You should use Next.js Image component here, I can't with my app because hobby plan has limits on image optimization.

<img
  src={`https://image.tmdb.org/t/p/w300${
  show.backdrop_path ?? show.poster_path
}`}
  alt="show-backdrop"
  width={240} //should be on-screen rendered size not image actual resolution
  height={135} //should be on-screen rendered size not image actual resolution
  className="aspect-video min-w-[160px] cursor-pointer object-cover transition-transform hover:scale-110 md:min-w-[240px]"
  />

12. You should move your linting and typechecking to Github workflows instead of Vercel, this will greatly reduce build times. It's very easy, just include this file in .github/workflows folder. "DATABASE_URL" can be any valid URL. If using T3 Env or bootrapping with CreateT3App set "SKIP_ENV_VALIDATION: true" to skip env check on Github.

13. Carousel is created using react-snap-carousel. Carousel drag scroll is implement using react-use-draggable-scroll. See my implementation here.

Follow and ask me questions at @Apestein_Dev.

About

Open source Netflix clone project using bleed-edge tech stack.

Resources

Stars

Watchers

Forks