Plex’s 2021 Journey with Next.js and Vercel

Reintroducing Plex’s 1M+ page media catalog on the web

Plex Engineering
Plex Labs
13 min readJan 7, 2022

--

2021 was a formative year for Plex on the web as we took some big strides in laying a unified foundation on which we can build our web experiences for years to come. It was capped by a December re-launch of watch.plex.tv, built with Next.js and deployed using Vercel. As we head into 2022, we’re taking a moment to reflect on the journey thus far.

A Brief History of Plex on the Web

  • 2010: When Plex Media Server was in its infancy, there was a native Media Manager app for macOS and a web-based alternative written in Cappuccino. While the native Media Manager felt fluid, the web-based app was slow and clunky in comparison.
  • 2012: Development started on the current Plex Web app, or app.plex.tv. It was built on top of Backbone.js and Marionette.js and was the first app to allow people to browse, manage, and play content from Plex Media Server on the web.
  • 2015: app.plex.tv began an incremental migration from Backbone.js to React, taking a step into practices that are table stakes for building a web app today, like declarative components.
  • 2017: Work started on the latest iteration of the marketing pages, or www.plex.tv. These pages are built primarily with Wordpress and jQuery, but have evolved to include Vue.js on some more client-heavy pages.
  • 2020: The original watch.plex.tv was launched, powered by a custom server-rendered React app using Express, MongoDB, and Redis.
  • 2020: TypeScript was introduced to app.plex.tv, forming the core of TypeScript, React, and Redux Toolkit on which the app is being built today.
  • 2021: A design system internally named Chroma was developed and work began on react-chroma to bring that design system to our React apps.

Our Key Priorities

As we reevaluated our existing web properties (www.plex.tv, app.plex.tv, and watch.plex.tv) in the beginning of 2021 and imagined a future direction for these properties, we discussed a few key priorities to help ground our decisions.

  • Aligned Tech: Different properties with very different tech and a small team meant it was easy to get trapped working in silos on code that was not easily reviewed. For us, aligning our tech starts with React as the base on top of which our web UIs are being built. Aligning on React then allows the following priorities to have more clarity.
  • Universal Navigation: Before the launch of the new watch.plex.tv, each site had its own navigation menu and traveling between the sites was especially awkward. The new watch.plex.tv and www.plex.tv now share the same navigation structure. Although it will not match app.plex.tv exactly as the responsibilities are very different, the general structure will start to align more closely.
  • Improved User Experience: Using our new React-based design system, we will be building with UI components that enforce a much stronger contract around appearance, accessibility, responsiveness, and touch friendliness. You’ll notice the new watch.plex.tv already shares more visual similarities with app.plex.tv, but with improved behavior on mobile and with screen readers.
  • Improved Developer Experience: As Plex grows and we work with an aligned stack across multiple teams, we want contributions to have a low barrier to entry and for iterating on a new feature or page to be as fast and intuitive as we can make it.

Looking at Next.js

With those priorities in mind, we took a long look at Next.js as the future of both watch.plex.tv and www.plex.tv. Next.js is a React framework with support for server-rendered pages that make it a great fit for our pages built with SEO in mind. Several things stood out to us as we looked at Next.js:

  • Flexibility: Next.js allows for static pages (getStaticProps), on demand and cached pages (getServerSideProps), or something in between (more on this later). Rewrites also give another type of flexibility that can be very powerful, like for a gradual migration from an existing site or even with something like geo-aware content (also more on this later).
  • Best Practices By Default: Next.js does a great job at either applying defaults that lead to a better experience or providing very easy components for doing so (and in some cases with lint rules to help enforce them). Route-based code splitting, the Image component with automatic image optimization and no layout shift, the Link component with pre-fetching, the Script component, and more all help make it easy for the team to stick to best practices and build a great experience.
  • Less Build Configuration: We’ve managed custom build configurations within app.plex.tv for years and have upgraded from webpack 2 to 3, to 4, to 5. Leaning on reasonable defaults and letting Next.js handle dev server and build configurations helps us focus more on our product while still providing a first class developer experience.
  • Documentation: It is very difficult to write and maintain documentation for a homegrown solution as well as open source projects like Next.js. By leaning on a proven framework, we are also benefiting from its documentation being a great starting point for a newcomer to the project.
  • Familiarity: Ultimately building a feature in a Next.js app doesn’t feel too different than building for app.plex.tv. The relatively thin layer on top of React is quick to learn. For the basics, there’s just a small set of new components and functions that can be exported alongside a page component. In the end, it’s still a React app that is able to use our new design system and interact with the same services as our client apps.
  • Non-Breaking Improvements: We felt Next.js has shown a good track record of making sound API decisions and adding improvements to the framework in non-breaking ways. This gives us confidence going forward that we’ll spend less time fighting upgrades and more time taking advantage of improvements.
  • Future Forward: The Next.js team has been active in exploring upcoming React 18 features. By using Next.js, we feel we’ll be in a good position to start taking advantage of new concurrent React features like SSR streaming as they’re ready for production.
  • Momentum: We simply couldn’t have built watch.plex.tv with Next.js when we started development on the original website as the features and flexibility just weren’t there. The pace at which Next.js has been improving gave us a lot of optimism, even with some of the gaps we encountered along the way. Vercel, the company that manages the Next.js project, has also been growing rapidly and has been building an impressive team. Leaders from projects like webpack, SWC, Svelte, Turborepo, React, and more have all joined Vercel in 2021, helping to crystallize Vercel’s vision for an improved developer experience.

Incremental Static with Millions of Pages

Plex’s media catalog on watch.plex.tv consists of potentially millions of movie, show, season, episode, actor, and director pages. A pure static site in the Jamstack philosophy would not be feasible due to the build time required to export all of these pages. That leaves us with two primary options using Next.js: traditional on demand and cached pages or incremental static pages.

The traditional approach would require a getServerSideProps function to be exported with a page that fetches data for the page. We would then also add a Cache-Control header to cache this page for a certain amount of time, so it does not need to fetch data for every request for the same page. The cached page can be served from a CDN much quicker on subsequent requests, until the cache expires. The next request after the cache expires would be blocked by the page fetching data again. There is a newer stale-while-revalidate response directive that allows for a stale response to be used while the page is recreated and fetches data again. However, support for this directive is a mixed landscape. Plex uses Cloudflare as a CDN for most of our data and Cloudflare’s interpretation of stale-while-revalidate support still blocks the first request for stale data and only allows subsequent parallel requests to respond with stale data while that blocked request is pending. The original watch.plex.tv ended up using a cron to pre-generate pages and store the pages in Redis to alleviate this issue, so a stale request could end up using the output stored in Redis rather than needing to query data and generate the page again. However, this is a layer of complexity we were hoping to leave behind.

Incremental static uses an exported getStaticProps function to fetch data for the page. A revalidate option can be returned that is similar to a Cache-Control max-age directive and allows a static page to be regenerated the next time it is requested after a certain amount of time has passed. A separate getStaticPaths function then controls the pages that are built upfront and a fallback option can be returned to allow pages not built upfront to be generated on demand. There are a few reasons to use incremental static over the traditional approach:

  1. A subset of pages returned from a separate getStaticPaths function can be generated at build time and these pages will never be blocked by needing to generate the page on demand. Other pages then fallback to being generated on demand. For watch.plex.tv, we can generate all of the movies and shows that we’re promoting at build time to ensure as fast an experience as possible browsing from the home page.
  2. After a static page is generated once, requests for that page will not be blocked by needing to generate the page on demand. Similar to the concept of stale-while-revalidate (depending on your CDN’s interpretation), a page that is older than the revalidate time will still be immediately used and the page is only re-generated in the background. Vercel also distributes static pages globally, so it is the first request in any region, although we are not taking advantage of this at Plex because of geo-restrictions, which we’ll dive into in the next section.
  3. Static pages are persisted per deploy, so rolling back to a previous deploy can use previously generated pages. Re-deploying will start fresh and fallback pages have to be generated again.

At the scale of millions of pages, incremental static can be a good fit but there is one important limitation to be aware of: the lack of on demand invalidation.

Edit: Next.js 12.1 has since added an unstable_revalidate function to revalidate individual pages using an API route. For more, see the blog post.

After a static page is generated, the only ways it can be regenerated is (1) a new deploy or (2) for the page’s age to exceed the revalidate time and then be requested again. With a cached page in Cloudflare, we can make a request to purge the cache specifically for that page if needed. With incremental static pages, there is no way to invalidate a specific page or set of pages on demand. With millions of static pages, deploying too often can have a negative impact because that requires fallback pages to be re-generated fresh again with blocking requests. Also, deploying can take some time depending on how many pages are prioritized to be built upfront for the deployment. A deployment of watch.plex.tv currently takes about 15 minutes and we’re generating a few thousand pages upfront, so if there is an issue that needs to be resolved quickly and a rollback isn’t appropriate, that 15 minutes to re-deploy can feel limiting.

Our movie pages change infrequently, but when they do change it’s important to reflect the latest quickly. A movie may become available to stream on the first of the month and it’s important for the document, including the page title and description, to reflect that the movie can be watched on Plex soon after the change. There are hundreds of thousands of movie pages, so serverless execution time can start to add up and get expensive if pages are constantly revalidating. We can optimize execution time by increasing our revalidate time and deploying less frequently than the revalidate time. However, by increasing the revalidate time, it increases the chance of being stuck with an out-of-date movie page. The ability to invalidate a page on demand without a deploy would allow our backend to trigger movie pages to regenerate after a change, which would then allow us to use a longer revalidate time and decrease our serverless execution time. It could also allow us to fix a critical issue more quickly without kicking off another deployment and invalidating every other page.

The Next.js team is researching improvements to this space, so hopefully we’ll see some changes in 2022 that alleviate this limitation with incremental static.

Geo-Restrictions and Header Rewrites

One of the features that unlocked Next.js with incremental static for watch.plex.tv is the ability to rewrite based on a header. Movies and shows are available to watch on different services in different countries, including Plex’s own on demand service. Browsing watch.plex.tv in one country will look different than browsing in another country. For this reason, our static pages need to be unique to the country from which the request originated. The original watch.plex.tv cached pages with Cloudflare’s geo option in the cache key to achieve a similar outcome.

Using a header rewrite, we can take the country code from a request header and rewrite the path to include that country code. While a visitor may see /movie/inception, internally that page is represented as /country/US/movie/inception and the static page will be unique for that country. We use file-glob to gather our geo-aware routes in the next.config.js file, so any page file that is dropped into the /country/[country] directory will automatically be unique per country.

The header rewrite for geo-aware pages combined with Next.js file-system based routing is self-documenting, making it clear which pages depend on the user’s country. Compared to managing Cloudflare cache keys external from the codebase, adding a file anywhere within our /country/[country] directory is guaranteed to be unique per country. Any page outside of that directory won’t be unique per country. API routes are discoverable in the same way. They live alongside pages in an api directory and it’s easy to look at the directory and know the sorts of API routes that are supported at a glance. Redirects and rewrites are also configured directly from next.config.js and work seamlessly in the development environment, maintaining that level of discoverability and approachability.

One tradeoff with the use of a header rewrite is the loss of client-side router functionality. Although the documentation states “rewrites are applied to client-side routing,” this does not currently work with a header rewrite. The client-side router is not currently able to resolve paths with header rewrites like is possible on the server. This interferes with several watch.plex.tv features:

  • Link pre-fetching does not work when linking to pages that have the header rewrite. This slows down the browse experience, especially when navigating to a fallback page that hasn’t been generated yet.
  • Losing client-side route transitions also impacts the browse experience and impacts smooth background transitions when navigating from one movie page to another.
  • fallback: true cannot be used, even after the bot-aware fallback feature was added in Next.js 12 to make this option viable for SEO conscious pages. Only fallback: 'blocking' works with pages that have the header rewrite.
  • router.replace to remove a param from the url of a page with the header rewrite will reload the page instead of performing a client-side replace.

Deploying with Vercel

Starting our journey with Next.js, we built a small prototype of watch.plex.tv using the framework and deployed it with Vercel. Both the speed at which the prototype was put together as well as the ease with which it could be deployed with Vercel had us immediately impressed. In less than 5 minutes, we had Vercel hooked into our Github repository with an integration that felt polished. Preview builds are automatically created from branches and a production build mirrors our main branch. Vercel automatically manages urls for these builds and it’s easy to set up a custom domain either for production or another branch like staging.

The original watch.plex.tv was deployed to Kubernetes clusters with auto-scaling servers. Leaving all that configuration behind and almost immediately having a production environment that could scale to our needs allowed a team focused more on frontend development to be autonomous and iterate more quickly on the product. At this stage in the project, the cost of Vercel was justified by the autonomy and speed it provided the team. Once we felt that momentum, we were reluctant to lose it.

Vercel as a deployment platform feels well designed and easy to use, but it’s not without some shortcomings. Our biggest pain point was that usage (bandwidth, serverless execution time, etc.) and project health can feel very opaque. Vercel has a real-time log viewer and a Usage page with broad stroke totals and extremely simplistic “Insights.” However, it relies more on third party logging tools to ingest logs and really be the primary tools that provide more insights into the health of a project by parsing the logs and presenting real-time and historical views into status codes, serverless function execution times, and more. Services like Logflare and Axiom, for example, can ingest logs from Vercel, allowing dashboards to be built with the data that expose information with much greater flexibility compared to Vercel’s Usage page. Vercel’s Analytics page does give per page metrics and allows for better visibility into a project’s Web Vitals. Hopefully the Usage page can grow more in this direction and better tools to monitor and optimize usage are built directly into Vercel. Other small quality of life features like being able to filter the Deployments list to production only or by branch would really improve the experience as well.

Launching the New watch.plex.tv

The new watch.plex.tv using Next.js has been deployed to production with Vercel and we’re excited to build on this foundation in 2022. The launch was relatively unexciting in a good way and Vercel had no issues scaling up to our production usage. Compared to the original watch.plex.tv, we’ve reduced the lines of app code by roughly 40% while adding new features like a custom video player with support for mobile, a universal navigation menu shared with www.plex.tv, and an enhanced quick search experience. The development environment is easier to set up and faster to iterate in. We know there is plenty that can still be optimized and improved, and we’re excited to see where the Next.js and Vercel ecosystem takes us. Here are a few things we’ll be looking at next:

  • Decrease the amount of JavaScript loaded with each page. Our React design system has a couple dependencies that are inflating our JavaScript size and changes to our components could eliminate the need for these dependencies reducing the amount of JavaScript loaded with a page significantly.
  • Optimize the API requests used by movie, show, and person pages. Currently a fallback page takes longer than it should to generate, partially due to some inefficient API requests. We’ll be speeding this up so a fallback page can deliver a better experience when the page is generated for the first time.
  • Start unifying www.plex.tv and watch.plex.tv, moving marketing and account pages to Next.js. This will help deliver a more cohesive look and feel with completely seamless navigation. Account pages moved from app.plex.tv will also be faster to load and provide a better user experience, especially on mobile.
  • Start bringing our new React design system to more of app.plex.tv. This will help improve the accessibility and responsiveness of app.plex.tv as well as ensure it stays closely aligned with our other websites.

If these challenges sound exciting, then take a look at our open positions and join the team building these experiences.

--

--