RFC: Deployment Adapters API · vercel next.js · Discussion #77740

7 min read Original article ↗

To ensure Next.js can be deployed anywhere, including severless platforms with custom requirements, we are planning to add deployment adapters. Vercel will use the same adapter API as every other partner.

Background

Next.js has supported self-hosting since 2016, including ejecting out of the Next.js server to a fully custom Express server. This includes deployment as a Node.js server (next start), standalone output, or a static export.

However, it has been difficult for serverless platforms like Netlify to support deploying Next.js due to their custom requirements. After talking with their team and others, we want to make Next.js easier to maintain for deployment platforms.

We also want to ensure Vercel adheres to the exact same adapter pattern, so there is parity between deployment providers for the Next.js output format used.

Pain points

After discussing with various teams from different providers and collecting their feedback, we have identified several pain points in the existing build outputs and our API surfaces.

We aim to address the following issues in this RFC:

No signal for when background work like revalidating is complete

  • Currently, to track background work, one must reach into the internals and reverse engineer how the request lifecycle is being handled.
  • An example of this can be seen here.

No ability to set or change specific next.config values without patching the file directly before a build

  • To modify configs in next.config today, providers have to either wrap their user’s next.config manually or use internal environment variables that aren’t officially documented (related example).

No build interface for receiving configs/entrypoint information without digging through undocumented manifests

  • The manifests Next.js creates today are undocumented and versioned separately from Next.js’ versioning, which can make them harder to track and rely on.
  • An example of the work needed to rely on one of these manifests can be seen here.

No stable interface for executing entrypoints without the full next-server

  • To successfully execute an entrypoint currently, the full next-server must be loaded and relied on. This causes pain from slowing down cold boots and giving less control over the request. Examples of handling that need to be added to leverage next-server in different environments can be seen here and here.

Proposed changes

Build output changes

To fix the core issue with executing entrypoints, we will remove the need to use next-server.

All entrypoints, including middleware, will now have a consistent signature that can be used:

// Node.js runtime
export async function handler(
  req: IncomingMessage,
  res: ServerResponse,
  ctx: { waitUntil: (promise: Promise<void>) => void }
): Promise<void>

// Edge runtime
export async function handler(
  req: Request,
  ctx: { waitUntil: (promise: Promise<void>) => void }
): Promise<Response>

Each entrypoint will now expose its signature directly, eliminating the need to load manifests during runtime. This allows each entrypoint to operate independently, loading only its required configuration.

To address concerns about knowing when background tasks are complete, a waitUntil callback can be provided. This callback will receive a promise that resolves once Next.js finishes processing tasks such as cache revalidation or executing after() callbacks.

If waitUntil is not provided, the promise returned by the handler method will not resolve until all background work is completed. For the edge runtime, this means the Response will not be returned immediately unless waitUntil is used.

Adapter API details

To alleviate two of the most significant pain points we've seen across providers—modifying next.config values and tracking outputs in the .next folder—we are implementing the following new adapter API:

interface NextAdapter {
  // "vercel", "netlify", "cloudflare" ...etc
  name: string,
  // "modifyConfig" is called before build and is an opportunity
  // to modify the loaded Next config
  modifyConfig(config: NextConfig)?: Promise<NextConfig> | NextConfig,
  // "built" is called after compilation and pre-rendering is finished
  // outputs will include all assets/functions we've created
  // it also includes public folder assets. Each pre-render path is
  // it's own function output with it's own config
  onBuildComplete({
    routes: {
      headers: Array<CustomRoute & { headers: Record<string, string> }>
      rewrites: Array<CustomRoute & { destination: string }>
      redirects: Array<CustomRoute & { destination: string, code: number }>
    },
    outputs: Array<{
      id: string,
      fallbackID?: string, // a fallbackID points to another output ID
      runtime?: 'nodejs' | 'edge',
      pathname: string,
      allowQuery?: string[]
      config: {
        // Any user provided function / pre-render config is provided here
        maxDuration?: number
        expiration?: number
        revalidate?: number
      },
      // These are the traced dependencies needed for the entry
      // to run in an isolated environment (standalone)
      // Record of file name (relative from project root) to absolute path
      assets?: Record<string, string>
      filePath: string,
      type: RouteType
    }>
  })?: Promise<void> | void
}

export type RouteHas =
  | {
      type: string
      key: string
      value?: string
    }
  | {
      type: 'host'
      key?: undefined
      value: string
    }
    
interface CustomRoute {
  source: string
  has: RouteHas[]
  missing: RouteHas[]
}

enum RouteType {
  /**
   * `PAGES` represents all the React pages that are under `pages/`.
   */
  PAGES = 'PAGES',
  /**
   * `PAGES_API` represents all the API routes under `pages/api/`.
   */
  PAGES_API = 'PAGES_API',
  /**
   * `APP_PAGE` represents all the React pages that are under `app/` with the
   * filename of `page.{j,t}s{,x}`.
   */
  APP_PAGE = 'APP_PAGE',
  /**
   * `APP_ROUTE` represents all the API routes and metadata routes that are under `app/` with the
   * filename of `route.{j,t}s{,x}`.
   */
  APP_ROUTE = 'APP_ROUTE',

  /**
   * `IMAGE` represents all the images that are generated by `next/image`.
   */
  IMAGE = 'IMAGE',
  
  /** 
   * `STATIC_FILE` represents a static file (ie /_next/static)
   */
  STATIC_FILE = 'STATIC_FILE',
  
  MIDDLEWARE = 'MIDDLEWARE',
}

Below is an example of the context provided to onBuildComplete for a minimal project.

/* Project structure:

app/
  page.tsx
  layout.tsx

middleware.ts
*/

onBuildComplete({
  routes: {
      headers: [
        // Next.js' default cache-control headers
        {
          source: '/_next/static/(.*)',
          headers: { 
            'cache-control': 'public,max-age=31536000,immutable'
          }
        },
        // user configured headers via next.config
      ],
      // user configured rewrites via next.config
      rewrites: [],
      // Next.js default redirects and user configured rewrites via next.config
      redirects: []
    },
    outputs: [
      {
        id: '1',
        fallbackID: 'fallback-1'
        runtime: 'nodejs',
        pathname: '/',
        allowQuery: []
        config: {
          revalidate: 30
        },
        // These are the traced dependencies needed for the entry
        // to run in an isolated environment (standalone)
        // Record of file name (relative from project root) to absolute path
        assets: {}
        filePath: '/Users/dev/my-app/.next/server/app/page.js',
        type: RouteType.APP_PAGE
      },
      // This is an example of a prerender fallback 
      {
        id: 'fallback-1',
        pathname: '/',
        filePath: '/Users/dev/my-app/.next/server/app/index.html',
        type: RouteType.STATIC_FILE
      },
      {
        id: 'middleware',
        runtime: 'edge',
        config: {
          matcher: []
        },
        pathname: '/',
        filePath: '/Users/dev/my-app/.next/server/middleware.js'
      },
      // This is a plain static chunk Next.js built
      {
        id: 'static-1',
        pathname: '/_next/static/chunks/app/page-34234.js',
        filePath: '/Users/dev/my-app/.next/static/chunks/app/page-34234.js',
        type: RouteType.STATIC_FILE
      },
      // "public/" folder files will be available here as well if present
    ]
})

To configure an adapter, users can install it in their package.json and specify it in the next.config file using the adapterPath option.

For platforms like Netlify or Vercel that aim to auto-configure the adapter, a default can be set using the NEXT_ADAPTER_PATH environment variable. However, any adapter explicitly configured by the user in next.config will take precedence over the one provided via the environment variable.

// next.config.ts
module.exports = {
  adpaterPath: require.resolve('@next/adapter-vercel')
}

Configured adapters are only utilized during builds to prevent conflicts in the development environment and simplify adapter implementations. If the need arises, we may explore dev-specific hooks for adapters in the future.

Adoption and rollout

We have created a working group with Netlify, Cloudflare, Amplify, and OpenNext. Together, we are designing and iterating on the API. We will begin by using the Vercel adapter to dogfood the new API.

Once these APIs reach stability, we will provide official documentation to ensure providers can fully understand and utilize them. Additionally, platforms which adhere to the adapter API will be listed in the Next.js deployment documentation.

We want to ensure the community can easily find and contribute to adapters, so we plan on hosting the repositories in the Next.js GitHub organization. We have already published a number of examples for deploying self-hosted Next.js applications here.

Not planned

We do not plan for this Adapters API to be used (or designed) for allowing custom runtimes currently.

For partners and providers

If you would like to collaborate with us on implementing an adapter, you can respond on this thread or reach out to jimmy.lai@vercel.com.