Run locally

When making some changes to OpenNext, it can be a bit cumbersome to need to deploy every time you want to test changes. If your change is not dependent on the wrapper or the converter, then you can create a custom open-next.config.ts file (you can use another name so that it doesn't conflict with your existing open-next.config.ts). Here is an example with a bunch of custom overrides:

To run OpenNext locally:

# This is to build (the config-path is needed if you use a different name than the default one)
node /path/to/open-next/packages/open-next/dist/index.js build --config-path open-next.local.config.ts
# Then to run the server
node .open-next/server-functions/default/index.mjs
// open-next.local.config.ts - 
// A good practice would be to use a different name so that it doesn't conflict 
// with your existing open-next.config.ts i.e. open-next.local.config.ts
import type {OpenNextConfig} from 'open-next/types/open-next'
 
const config = {
  default: {
    override:{
      // We use a custom wrapper so that we can use static assets and image optimization locally
      wrapper: () => import('./dev/wrapper').then(m => m.default),
      // ISR and SSG won't work properly locally without this - Remove if you only need SSR
      incrementalCache: () => import('./dev/incrementalCache').then(m => m.default),
      // ISR requires a queue to work properly - Remove if you only need SSR or SSG
      queue: () => import('./dev/queue').then(m => m.default),
      converter: 'node',
    }
  },
  // You don't need this part if you don't use image optimization or don't need it in your test
  imageOptimization: {
    // Image optimization only work on linux, and you have to use the correct architecture for your system
    arch: 'x64',
    override: {
      wrapper: 'node',
      converter: 'node',
    }
    // If you need to test with local assets, you'll have to override the imageLoader as well
  },
 
  dangerous: {
    // We disable the cache tags as it will usually not be needed locally for testing
    // It's only used for next/cache revalidateTag and revalidatePath
    // If you need it you'll have to override the tagCache as well
    disableTagCache: true,
 
 
    // You can uncomment this line if you only need to test SSR
    //disableIncrementalCache: true,
  },
  // You can override the build command so that you don't have to rebuild the app every time
  // You need to have run the default build command at least once
  buildCommand: 'echo "no build command"',
  edgeExternals: ['./dev/wrapper', './dev/incrementalCache', './dev/queue'],
} satisfies OpenNextConfig
 
export default config
// dev/wrapper.ts
// You'll need to install express
import express from 'express'
// The proxy is used to proxy the image optimization server 
// you don't have to use it, but image request will return 500 error
import proxy from 'express-http-proxy'
import { fork } from 'child_process'
 
import type { StreamCreator } from "open-next/http/openNextResponse";
import type { WrapperHandler } from "open-next/types/open-next";
 
const wrapper: WrapperHandler = async (handler, converter) => {
  const app = express();
  // To serve static assets
  app.use(express.static('../../assets'))
 
  //Launch image server fork
  fork('../../image-optimization-function/index.mjs', [], {
    env: {
      NODE_ENV: 'development',
      PORT: '3001',
    }
  })
  app.use('/_next/image', proxy('localhost:3001'))
 
  app.all('*', async (req, res) => {
    const internalEvent = await converter.convertFrom(req);
    const _res : StreamCreator = {
      writeHeaders: (prelude) => {
        res.writeHead(prelude.statusCode, prelude.headers);
        res.uncork();
        return res;
      },
      onFinish: () => {
        // Is it necessary to do something here?
      },
    };
    await handler(internalEvent, _res);
  });
 
  const server = app.listen(parseInt(process.env.PORT ?? "3000", 10), ()=> {
    console.log(`Server running on port ${process.env.PORT ?? 3000}`);
  })
 
 
  app.on('error', (err) => {
    console.error('error', err);
  });
 
  return () => {
    server.close();
  };
};
 
export default {
  wrapper,
  name: "dev-node",
  supportStreaming: true,
};
// dev/incrementalCache.ts
import type {IncrementalCache} from 'open-next/cache/incremental/types'
 
import fs from 'node:fs/promises'
import path from 'node:path'
 
const buildId = process.env.NEXT_BUILD_ID
const basePath= path.resolve(process.cwd(), `../../cache/${buildId}`)
 
const getCacheKey = (key: string) => {
  return path.join(basePath, `${key}.cache`)
}
 
const cache: IncrementalCache = {
  name: 'dev-fs',
  get: async (key: string) => {
    const fileData = await fs.readFile(getCacheKey(key), 'utf-8')
    const data = JSON.parse(fileData)
    const {mtime} = await fs.stat(getCacheKey(key))
    return {
      value: data,
      lastModified: mtime.getTime(),
    }
  },
  set: async (key, value, isFetch) => {
    const data = JSON.stringify(value)
    await fs.writeFile(getCacheKey(key), data)
  },
  delete: async (key) => {
    await fs.rm(getCacheKey(key))
  }
}
 
export default cache
// dev/queue.ts
import type {Queue} from 'open-next/queue/types'
 
declare global {
  // This is declared in the global scope so that we can use it in the queue
  // We need to use this one as next overrides the global fetch
  var internalFetch: typeof fetch
}
 
const queue: Queue = {
  name: 'dev-queue',
  send: async (message) => {
    const prerenderManifest  = (await import('open-next/adapters/config')).PrerenderManifest as any
    const revalidateId : string = prerenderManifest.preview.previewModeId
    await globalThis.internalFetch(`http://localhost:3000${message.MessageBody.url}`, {
      method: "HEAD",
      headers: {
        "x-prerender-revalidate": revalidateId,
        "x-isr": "1",
      },
    },)
    console.log('sending message', message)
  },
}
 
export default queue