Skip to content

feat: add Netlify as a deployment target (PoC)#76

Draft
serhalp wants to merge 1 commit intocloudflare:mainfrom
serhalp:feat/netlify-poc
Draft

feat: add Netlify as a deployment target (PoC)#76
serhalp wants to merge 1 commit intocloudflare:mainfrom
serhalp:feat/netlify-poc

Conversation

@serhalp
Copy link

@serhalp serhalp commented Feb 25, 2026

Add Netlify support alongside the existing Cloudflare Workers target.

Caution

This was fully implemented by Claude Code as a proof of concept. I have not reviewed the code at all. Do not use this for anything serious.

  • Detect @netlify/vite-plugin and configure builds accordingly (manifest generation, SSR externalization, etc.)
  • Add vinext:netlify-build plugin that generates .netlify/v1/functions/server.mjs and _headers for immutable asset caching (App Router + Pages Router)
  • Use dynamic import() in generated wrapper so NODE_ENV is set before React loads (static imports are hoisted past statements in ESM)
  • Handle Pages Router two-pass build: client closeBundle generates wrapper, SSR closeBundle patches server entry with globals
  • Add BlobCacheHandler using deploy-scoped @netlify/blobs for ISR caching
  • Add vinext/netlify package export
  • Add examples: app-router-netlify, pages-router-netlify, hackernews-netlify

Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com

Add Netlify support alongside the existing Cloudflare Workers target.

- Detect `@netlify/vite-plugin` and configure builds accordingly (manifest generation, SSR
  externalization, etc.)
- Add `vinext:netlify-build` plugin that generates `.netlify/v1/functions/server.mjs` and `_headers`
  for immutable asset caching (App Router + Pages Router)
- Use dynamic `import()` in generated wrapper so `NODE_ENV` is set before React loads (static
  imports are hoisted past statements in ESM)
- Handle Pages Router two-pass build: client closeBundle generates wrapper, SSR closeBundle patches
  server entry with globals
- Add `BlobCacheHandler` using deploy-scoped `@netlify/blobs` for ISR caching
- Add `vinext/netlify` package export
- Add examples: app-router-netlify, pages-router-netlify, hackernews-netlify

---
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
## Project Overview

**vinext** is a Vite plugin that reimplements the Next.js API surface, with Cloudflare Workers as the primary deployment target. The goal: take any Next.js app and deploy it to Workers with one command.
**vinext** is a Vite plugin that reimplements the Next.js API surface, with Cloudflare Workers and Netlify as deployment targets. The goal: take any Next.js app and deploy it to Workers or Netlify with one command.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't scale, should probably be generalized

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😁 As a big proponent of the Deployment Adapter API, I'm well aware, Fred! This is just a PoC.

@southpolesteve
Copy link
Collaborator

Thank you for showing its possible. This was the dream I had when I built it on vite. Filed #80 to talk through what doing this in a pluggable way looks like

@southpolesteve
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Netlify deployment target PoC

Nice proof-of-concept. The structural symmetry with the Cloudflare build plugin makes it easy to follow, the cache handler is thorough, and the examples all have working deploy URLs. A few things to address before this moves beyond PoC status.

Key observations

  1. @netlify/blobs is a hard dependency for all vinext users. Unlike the Cloudflare KV handler (which defines its own KVNamespace interface inline and has zero npm dependencies), the Netlify blob handler imports getStore from @netlify/blobs at the top level. This means every npm install vinext pulls in @netlify/blobs even if the user is deploying to Cloudflare Workers. This should be an optionalDependencies or peerDependencies with optional: true — or the import should be dynamic.

  2. ~190 lines of duplicated build logic. The manifest reading, lazy chunk computation, SSR manifest reading, globals injection, and _headers generation in vinext:netlify-build are nearly identical to vinext:cloudflare-build. As noted in the existing review comment about pluggability (#80), extracting shared helpers would reduce drift risk between the two targets.

  3. Plugin ordering is inconsistent across examples. hackernews-netlify uses [netlify(), vinext()] while the other two examples use [vinext(), netlify()]. This should be consistent, and ideally documented which ordering is correct (or assert that both work).

  4. hackernews-netlify is a near-complete copy of hackernews/. Only layout.tsx, server-info.jsx, vite.config.ts, and package.json differ. The 40+ duplicated files will drift independently. Consider making the example share source with the original (symlinks, workspace references, or a single example with both configs) or at minimum document this is intentional.

  5. No tests. The AGENTS.md instructions say "Add tests first". Even for a PoC, at least a build smoke test (does vite build succeed for the Netlify examples?) would catch regressions when the core changes.

"vite": "^7.0.0"
},
"dependencies": {
"@netlify/blobs": "^10.7.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes @netlify/blobs a hard dependency for all vinext installs, including Cloudflare-only users. The Cloudflare KV handler avoids this by defining its own KVNamespace interface inline with no npm dep.

Consider making this an optional peer dependency:

Suggested change
"@netlify/blobs": "^10.7.0",

and adding to peerDependencies:

"@netlify/blobs": { "optional": true }

Alternatively, use a dynamic import("@netlify/blobs") in the cache handler so it only fails at runtime if someone actually uses vinext/netlify without installing the package.

import netlify from "@netlify/vite-plugin";

export default defineConfig({
plugins: [netlify(), vinext()],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin order is [netlify(), vinext()] here but [vinext(), netlify()] in the other two Netlify examples. This should be consistent — if ordering doesn't matter, pick one and use it everywhere.

order: "post",
async handler() {
const envName = (this as any).environment?.name as string | undefined;
if (!envName || !hasNetlifyPlugin || hasCloudflarePlugin) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If both plugins are detected, Cloudflare silently wins and the Netlify build is skipped entirely. This is a reasonable precedence rule, but it's invisible to the user. Consider logging a warning if both are detected, since having both plugins registered is almost certainly a misconfiguration:

if (hasNetlifyPlugin && hasCloudflarePlugin) {
  console.warn('[vinext] Both Cloudflare and Netlify plugins detected; using Cloudflare. Remove one.');
}

}
const lazy = computeLazyChunks(buildManifest);
if (lazy.length > 0) lazyChunksData = lazy;
} catch { /* ignore parse errors */ }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent catch {} here means a corrupted manifest file will result in no lazy chunk filtering and no SSR manifest injection — all chunks get modulepreloaded on every page, silently defeating code-splitting. The Cloudflare build has the same issue (catch { /* ignore parse errors */ }). At minimum, log a warning so the user knows something went wrong:

Suggested change
} catch { /* ignore parse errors */ }
} catch (e) { console.warn('[vinext] Failed to parse build manifest:', e); }

` return handleApiRoute(request, urlWithQuery);`,
` }`,
``,
` return renderPage(request, urlWithQuery, null);`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Pages Router wrapper doesn't invoke middleware. Looking at the Cloudflare Pages Router path in server/prod-server.ts and cloudflare/worker-entry.ts, middleware is run before routing. Here the wrapper goes straight to handleApiRoute or renderPage. If renderPage internally runs middleware, this is fine — but it should be documented with a comment explaining that middleware execution is handled inside the virtual server entry, not in this wrapper.

return true;
}

function arrayBufferToBase64(buffer: ArrayBuffer): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manual String.fromCharCode loop for ArrayBuffer<->base64 is O(n) string concatenation per byte (quadratic in many JS engines due to string immutability). For large RSC payloads or images this could be slow.

Node.js has built-in Buffer.from(buffer).toString('base64') and Buffer.from(base64, 'base64') which are native C++ and much faster. The Netlify Functions runtime is Node.js, so Buffer is always available.

Suggested change
function arrayBufferToBase64(buffer: ArrayBuffer): string {
function arrayBufferToBase64(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString('base64');
}

const tagTime = tagResults[i];
if (tagTime) {
const tagTimestamp = Number(tagTime);
if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When tagTimestamp is NaN (corrupted tag value), this treats the entry as invalidated. That's a reasonable safety default, but it means a single corrupted tag timestamp can cascade-invalidate every cache entry that references that tag. Consider logging a warning when a corrupted tag is encountered so it's debuggable:

Suggested change
if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) {
if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) {
// Tag was invalidated after this entry, or timestamp is corrupted
// — treat as miss to force re-render
if (Number.isNaN(tagTimestamp)) {
console.warn(`[vinext] Corrupted tag timestamp for tag "${entry.tags[i]}", treating as invalidated`);
}

}

private getStore() {
return getStore(this.storeName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling getStore() on every cache operation (every get, set, revalidateTag) creates a new store client each time. If @netlify/blobs's getStore does any setup work (auth, config resolution), this could add latency. Consider caching the store instance:

Suggested change
return getStore(this.storeName);
private getStore() {
this._store ??= getStore(this.storeName);
return this._store;
}

(with a private _store: ReturnType<typeof getStore> | undefined; field)

@@ -0,0 +1,3 @@
[build]
command = "pnpm run build"
publish = "dist"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The publish directory is dist for Pages Router but dist/client for App Router. The _headers file generation in vinext:netlify-build (line 3390) also branches on hasAppDir to determine the publish dir. This works, but it's a subtle correctness dependency — if someone creates a Pages Router app with a different output structure, the _headers file would be generated in the wrong place. Worth a comment in the code explaining why these differ.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Feb 25, 2026

Review submitted successfully. Here's a summary of the feedback I posted:

Review Summary

Top-level observations:

  1. @netlify/blobs as a hard dependency — Unlike the Cloudflare KV handler (which uses an inline interface with zero npm deps), this adds @netlify/blobs to dependencies, pulling it into every vinext install. Should be optional/peer or dynamically imported.
  2. ~190 lines of duplicated build logic between vinext:cloudflare-build and vinext:netlify-build — manifest reading, lazy chunk computation, globals injection, _headers generation are nearly identical. Aligns with Pluggable deployment adapters: support Cloudflare, Vercel, Netlify, and custom targets #80's pluggability discussion.
  3. Inconsistent plugin ordering across the three Netlify examples ([netlify(), vinext()] vs [vinext(), netlify()]).
  4. hackernews-netlify is a near-full copy of hackernews/ — 40+ identical files that will drift.
  5. No tests — even a build smoke test would catch regressions.

Inline comments (9 total):

  • @netlify/blobs should be optional (package.json:63)
  • Plugin ordering inconsistency (hackernews-netlify vite.config.ts:6)
  • Silent Cloudflare-wins-over-Netlify precedence should warn (index.ts:3234)
  • Silent catch {} on manifest parse errors defeats code-splitting silently (index.ts:3270)
  • Pages Router wrapper skips middleware — needs documentation comment (index.ts:3346)
  • ArrayBuffer<->base64 uses quadratic string concatenation; should use Node.js Buffer (blob-cache-handler.ts:158)
  • Corrupted tag timestamp cascades invalidation without logging (blob-cache-handler.ts:245)
  • getStore() called on every operation; should cache the store instance (blob-cache-handler.ts:197)
  • Publish dir branching between App/Pages Router is a subtle correctness dependency (pages-router-netlify netlify.toml:3)

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants