Skip to content

feat: use edge functions for content negotiation by default #1438

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 53 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,56 +37,39 @@ If you build on Netlify, this plugin will work with no additional configuration.
deploying locally using the Netlify CLI, you must deploy using `netlify deploy --build`. Running the build and deploy
commands separately will not work, because the plugin will not generate the required configuration.

## Migrating from an older version of the plugin

You can manually upgrade from the previous version of the plugin by running the following command:

```shell
npm install -D @netlify/plugin-nextjs@latest
```

Change the `publish` directory to `.next`:

```toml
[build]
publish = ".next"
```

If you previously set these values, they're no longer needed and can be removed:

- `distDir` in your `next.config.js`
- `node_bundler = "esbuild"` in `netlify.toml`
- `external_node_modules` in `netlify.toml`

The `serverless` and `experimental-serverless-trace` targets are deprecated in Next 12, and all builds with this plugin
will now use the default `server` target. If you previously set the target in your `next.config.js`, you should remove
it.

If you currently use redirects or rewrites on your site, see
[the Rewrites and Redirects guide](https://github.com/netlify/netlify-plugin-nextjs/blob/main/docs/redirects-rewrites.md)
for information on changes to how they are handled in this version. In particular, note that `_redirects` and `_headers`
files must be placed in `public`, not in the root of the site.
## Using `next/image`

If you use [`next/image`](https://nextjs.org/docs/basic-features/image-optimization), your images will be automatically
optimized at runtime, ensuring that they are served at the best size and format. The image will be processed on the
first request which means it may take longer to load, but the generated image is then cached at the edge and served as a
static file to future visitors. By default, Next will deliver WebP images if the browser supports it. WebP is a new
image format with wide browser support that will usually generate smaller files than png or jpg. You can additionally
enable the AVIF format, which is often even smaller in filesize than WebP. The drawback is that with particularly large
images AVIF may take too long to generate, meaning the function times-out. You can configure
[the supported image formats](https://nextjs.org/docs/api-reference/next/image#acceptable-formats) in your
`next.config.js` file.

In order to deliver the correct format to a visitor's browser, this uses a Netlify Edge Function. In some cases your
site may not support Edge Functions, in which case it will instead fall back to delivering the original file format. You
may also manually disable the Edge Function by setting the environment variable `NEXT_DISABLE_EDGE_IMAGES` to `true`.

## Next.js Middleware on Netlify

Next.js Middleware works out of the box on Netlify, but check out the
[docs on some caveats](https://github.com/netlify/netlify-plugin-nextjs/blob/main/docs/middleware.md). By default,
middleware runs using SSR. For better results, you should enable [Netlify Edge Functions](#netlify-edge-functions),
which ensures middleware runs at the edge.
which ensures middleware runs at the edge. To use Netlify Edge Functions for middleware or to enable
[edge server rendering](https://nextjs.org/blog/next-12-2#edge-server-rendering-experimental), set the environment
variable `NEXT_USE_NETLIFY_EDGE` to `true`.

### No nested middleware in Next 12.2.0

In Next 12.2.0, nested middleware [has been deprecated](https://nextjs.org/docs/messages/middleware-upgrade-guide) in
favor of root level middleware. If you are not using edge functions then this means that you won't get the benefits of
using a CDN, and ISR will not work.

To fix this issue, you can run your middleware on [Netlify Edge Functions](#netlify-edge-functions).

## Netlify Edge Functions

To use Netlify Edge Functions for middleware or to enable
[edge server rendering](https://nextjs.org/blog/next-12-2#edge-server-rendering-experimental), set the environment
variable `NEXT_USE_NETLIFY_EDGE` to `true`.
To fix this issue, you can run your middleware on [Netlify Edge Functions](#netlify-edge-functions) by setting the
environment variable `NEXT_USE_NETLIFY_EDGE` to `true`.

## Monorepos

Expand Down Expand Up @@ -123,6 +106,39 @@ images). You can see the requests for these in [the function logs](https://docs.
and fallback routes you will not see any requests that are served from the edge cache, just actual rendering requests.
These are all internal functions, so you won't find them in your site's own functions directory.

The plugin will also generate a Netlify Edge Function called 'ipx' to handle image content negotiation, and if Edge
runtime or middleware is enabled it will also generate edge functions for middleware and edge routes.

## Migrating from an older version of the plugin

You can manually upgrade from the previous version of the plugin by running the following command:

```shell
npm install -D @netlify/plugin-nextjs@latest
```

Change the `publish` directory to `.next`:

```toml
[build]
publish = ".next"
```

If you previously set these values, they're no longer needed and can be removed:

- `distDir` in your `next.config.js`
- `node_bundler = "esbuild"` in `netlify.toml`
- `external_node_modules` in `netlify.toml`

The `serverless` and `experimental-serverless-trace` targets are deprecated in Next 12, and all builds with this plugin
will now use the default `server` target. If you previously set the target in your `next.config.js`, you should remove
it.

If you currently use redirects or rewrites on your site, see
[the Rewrites and Redirects guide](https://github.com/netlify/netlify-plugin-nextjs/blob/main/docs/redirects-rewrites.md)
for information on changes to how they are handled in this version. In particular, note that `_redirects` and `_headers`
files must be placed in `public`, not in the root of the site.

## Feedback

If you think you have found a bug in the plugin,
Expand Down
3 changes: 3 additions & 0 deletions demos/canary/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const nextConfig = {
// your project has ESLint errors.
ignoreDuringBuilds: true,
},
images: {
formats: ['image/avif', 'image/webp'],
},
experimental: {
images: {
remotePatterns: [
Expand Down
26 changes: 19 additions & 7 deletions docs/isr.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ request is made for stale content, the page will be regenerated in the backgroun
but it can take up to 60 seconds before the new content is then updated in all CDN nodes if they already had a cached
copy.

If the static regeneration relies on local files in your repository they need to be bundled with the handler functions.
This can be done by modifying your [file based configuration](https://docs.netlify.com/configure-builds/file-based-configuration).
An entry to the `included_files` option needs to be added under the `functions` option. You should be careful to not include unnecessary files, particularly large files such as images or videos, because there is a 50MB size limit for each handler function.
See [Functions Configuration Docs](https://docs.netlify.com/configure-builds/file-based-configuration/#functions) for more info.
Update your `netlify.toml` file to include the following (assuming local content is located in the /content directory):
If the static regeneration relies on local files in your repository they need to be bundled with the handler functions.
This can be done by modifying your
[file based configuration](https://docs.netlify.com/configure-builds/file-based-configuration). An entry to the
`included_files` option needs to be added under the `functions` option. You should be careful to not include unnecessary
files, particularly large files such as images or videos, because there is a 50MB size limit for each handler function.
See [Functions Configuration Docs](https://docs.netlify.com/configure-builds/file-based-configuration/#functions) for
more info. Update your `netlify.toml` file to include the following (assuming local content is located in the /content
directory):

```toml
[functions]
included_files = ["content/**"]
Expand All @@ -37,30 +41,38 @@ If you only need the content for DSG pages, then you can target only that functi
[functions.__dsg]
included_files = ["content/**"]
```

or, for SSR pages:

```toml
[functions.__ssr]
included_files = ["content/**"]
```

If a new deploy is made, all persisted pages and CDN cached pages will be invalidated so that conflicts are avoided. If
this did not happen, a stale HTML page might make a request for an asset that no longer exists in the new deploy. By
invalidating all persisted pages, you can be confident that this will never happen and that deploys remain atomic.

### On-demand ISR

On-Demand ISR (where a path is manually revalidated) is not currently supported on Netlify.
[Please let us know](https://github.com/netlify/netlify-plugin-nextjs/discussions/1228) if this feature would be useful
to you, and if so how you would plan to use it.

### Alternatives to ISR

ISR is best for situations where there are regular updates to content throughout the day, particularly you don't have
control over when it happens. It is less ideal in situations such as a CMS with incremental updates where you can have
the CMS trigger a deploy when a page is added or edited. This offers the best performance and avoids unnecesary
rebuilds.

### Static site generation
#### Static site generation

For high-traffic pages you can use
[static generation](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) without
`revalidate`, which deploys static files directly to the CDN for maximum performance.

### Distributed persistent rendering
#### Distributed persistent rendering

For less commonly-accessed content you can use return `fallback: "blocking"` from
[`getStaticPaths`](https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation) and defer
Expand Down
56 changes: 31 additions & 25 deletions plugin/src/helpers/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,6 @@ const writeEdgeFunction = async ({
* Writes Edge Functions for the Next middleware
*/
export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => {
const middlewareManifest = await loadMiddlewareManifest(netlifyConfig)
if (!middlewareManifest) {
console.error("Couldn't find the middleware manifest")
return
}

const manifest: FunctionManifest = {
functions: [],
version: 1,
Expand All @@ -139,35 +133,47 @@ export const writeEdgeFunctions = async (netlifyConfig: NetlifyConfig) => {
const edgeFunctionRoot = resolve('.netlify', 'edge-functions')
await emptyDir(edgeFunctionRoot)

await copyEdgeSourceFile({ edgeFunctionDir: edgeFunctionRoot, file: 'ipx.ts' })

manifest.functions.push({
function: 'ipx',
path: '/_next/image*',
})

for (const middleware of middlewareManifest.sortedMiddleware) {
const edgeFunctionDefinition = middlewareManifest.middleware[middleware]
const functionDefinition = await writeEdgeFunction({
edgeFunctionDefinition,
edgeFunctionRoot,
netlifyConfig,
if (!process.env.NEXT_DISABLE_EDGE_IMAGES) {
if (!process.env.NEXT_USE_NETLIFY_EDGE) {
console.log(
'Using Netlify Edge Functions for image format detection. Set env var "NEXT_DISABLE_EDGE_IMAGES=true" to disable.',
)
}
await copyEdgeSourceFile({ edgeFunctionDir: edgeFunctionRoot, file: 'ipx.ts' })
manifest.functions.push({
function: 'ipx',
path: '/_next/image*',
})
manifest.functions.push(functionDefinition)
}
// Older versions of the manifest format don't have the functions field
// No, the version field was not incremented
if (typeof middlewareManifest.functions === 'object') {
for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) {
if (process.env.NEXT_USE_NETLIFY_EDGE) {
const middlewareManifest = await loadMiddlewareManifest(netlifyConfig)
if (!middlewareManifest) {
console.error("Couldn't find the middleware manifest")
return
}

for (const middleware of middlewareManifest.sortedMiddleware) {
const edgeFunctionDefinition = middlewareManifest.middleware[middleware]
const functionDefinition = await writeEdgeFunction({
edgeFunctionDefinition,
edgeFunctionRoot,
netlifyConfig,
})
manifest.functions.push(functionDefinition)
}
// Older versions of the manifest format don't have the functions field
// No, the version field was not incremented
if (typeof middlewareManifest.functions === 'object') {
for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) {
const functionDefinition = await writeEdgeFunction({
edgeFunctionDefinition,
edgeFunctionRoot,
netlifyConfig,
})
manifest.functions.push(functionDefinition)
}
}
}

await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest)
}

Expand Down
17 changes: 8 additions & 9 deletions plugin/src/helpers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,14 @@ export const setupImageFunction = async ({

const imagePath = imageconfig.path || '/_next/image'

// If we have edge, we use content negotiation instead of the redirect
if (!process.env.NEXT_USE_NETLIFY_EDGE) {
netlifyConfig.redirects.push({
from: `${imagePath}*`,
query: { url: ':url', w: ':width', q: ':quality' },
to: `${basePath}/${IMAGE_FUNCTION_NAME}/w_:width,q_:quality/:url`,
status: 301,
})
}
// If we have edge functions then the request will have already been rewritten
// so this won't match. This is matched if edge is disabled or unavailable.
netlifyConfig.redirects.push({
from: `${imagePath}*`,
query: { url: ':url', w: ':width', q: ':quality' },
to: `${basePath}/${IMAGE_FUNCTION_NAME}/w_:width,q_:quality/:url`,
status: 301,
})

netlifyConfig.redirects.push({
from: `${basePath}/${IMAGE_FUNCTION_NAME}/*`,
Expand Down
4 changes: 3 additions & 1 deletion plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,14 @@ const plugin: NetlifyPlugin = {
buildId,
})

// We call this even if we don't have edge functions enabled because we still use it for images
await writeEdgeFunctions(netlifyConfig)

if (process.env.NEXT_USE_NETLIFY_EDGE) {
console.log(outdent`
✨ Deploying to ${greenBright`Netlify Edge Functions`} ✨
This feature is in beta. Please share your feedback here: https://ntl.fyi/next-netlify-edge
`)
await writeEdgeFunctions(netlifyConfig)
await updateConfig(publish)
}

Expand Down
52 changes: 36 additions & 16 deletions plugin/src/templates/edge/ipx.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,52 @@
import { Accepts } from 'https://deno.land/x/accepts/mod.ts'
import type { Context } from 'netlify:edge'
import { Accepts } from "https://deno.land/x/[email protected]/mod.ts";
import type { Context } from "netlify:edge";
import imageconfig from "../functions-internal/_ipx/imageconfig.json" assert {
type: "json",
};

const defaultFormat = "webp"

/**
* Implement content negotiation for images
*/

// deno-lint-ignore require-await
const handler = async (req: Request, context: Context) => {
const { searchParams } = new URL(req.url)
const accept = new Accepts(req.headers)
const type = accept.types(['avif', 'webp'])
const { searchParams } = new URL(req.url);
const accept = new Accepts(req.headers);
const { formats = [defaultFormat] } = imageconfig;
if (formats.length === 0) {
formats.push(defaultFormat);
}
let type = accept.types(formats) || defaultFormat;
if(Array.isArray(type)) {
type = type[0];
}

const source = searchParams.get('url')
const width = searchParams.get('w')
const quality = searchParams.get('q') ?? 75

const source = searchParams.get("url");
const width = searchParams.get("w");
const quality = searchParams.get("q") ?? 75;

if (!source || !width) {
return new Response('Invalid request', {
return new Response("Invalid request", {
status: 400,
})
});
}

const modifiers = [`w_${width}`, `q_${quality}`]
const modifiers = [`w_${width}`, `q_${quality}`];

if (type) {
modifiers.push(`f_${type}`)
if(type.includes('/')) {
// If this is a mimetype, strip "image/"
type = type.split('/')[1];
}
modifiers.push(`f_${type}`);
}
const target = `/_ipx/${modifiers.join(",")}/${encodeURIComponent(source)}`;
return context.rewrite(
target,
);
};

return context.rewrite(`/_ipx/${modifiers.join(',')}/${encodeURIComponent(source)}`)
}

export default handler
export default handler;