Open source 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).
- Next.js 13
- CreateT3App Bootrapped
- Neon (postgres)
- Drizzle ORM
- Tailwind + Shadcn/ui
- Clerk
- SWR
- Lucide Icons
- Zod Validation
- Stripe
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.
Parallel and intercepting routes currently very broken, not worth using. But their usefullness would be immese if they actually worked correctly. Being able to show a modal that is fetched with server components is huge. I couldn't find a way to use server component with my modal so it had to be fetched with client component. 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. Important note, I was using Zact at first but it was much too limited, just use next-safe-actions instead. 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.
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.
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.
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 when.
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),
}),
}
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
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. I decided to use server action to query initial state just to test it, but for client side querying it's probably better to use React Query/SWR.
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>