feat: add Netlify as a deployment target (PoC)#76
feat: add Netlify as a deployment target (PoC)#76serhalp wants to merge 1 commit intocloudflare:mainfrom
Conversation
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>
ddcfd60 to
1a2620f
Compare
| ## 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. |
There was a problem hiding this comment.
won't scale, should probably be generalized
There was a problem hiding this comment.
😁 As a big proponent of the Deployment Adapter API, I'm well aware, Fred! This is just a PoC.
|
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 |
|
/bigbonk review |
There was a problem hiding this comment.
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
-
@netlify/blobsis a hard dependency for all vinext users. Unlike the Cloudflare KV handler (which defines its ownKVNamespaceinterface inline and has zero npm dependencies), the Netlify blob handler importsgetStorefrom@netlify/blobsat the top level. This means everynpm install vinextpulls in@netlify/blobseven if the user is deploying to Cloudflare Workers. This should be anoptionalDependenciesorpeerDependencieswithoptional: true— or the import should be dynamic. -
~190 lines of duplicated build logic. The manifest reading, lazy chunk computation, SSR manifest reading, globals injection, and
_headersgeneration invinext:netlify-buildare nearly identical tovinext:cloudflare-build. As noted in the existing review comment about pluggability (#80), extracting shared helpers would reduce drift risk between the two targets. -
Plugin ordering is inconsistent across examples.
hackernews-netlifyuses[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). -
hackernews-netlifyis a near-complete copy ofhackernews/. Onlylayout.tsx,server-info.jsx,vite.config.ts, andpackage.jsondiffer. 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. -
No tests. The AGENTS.md instructions say "Add tests first". Even for a PoC, at least a build smoke test (does
vite buildsucceed for the Netlify examples?) would catch regressions when the core changes.
| "vite": "^7.0.0" | ||
| }, | ||
| "dependencies": { | ||
| "@netlify/blobs": "^10.7.0", |
There was a problem hiding this comment.
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:
| "@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()], |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 */ } |
There was a problem hiding this comment.
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:
| } catch { /* ignore parse errors */ } | |
| } catch (e) { console.warn('[vinext] Failed to parse build manifest:', e); } |
| ` return handleApiRoute(request, urlWithQuery);`, | ||
| ` }`, | ||
| ``, | ||
| ` return renderPage(request, urlWithQuery, null);`, |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
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:
| 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); |
There was a problem hiding this comment.
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:
| 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" | |||
There was a problem hiding this comment.
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.
|
Review submitted successfully. Here's a summary of the feedback I posted: Review SummaryTop-level observations:
Inline comments (9 total):
|
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.
@netlify/vite-pluginand configure builds accordingly (manifest generation, SSR externalization, etc.)vinext:netlify-buildplugin that generates.netlify/v1/functions/server.mjsand_headersfor immutable asset caching (App Router + Pages Router)import()in generated wrapper soNODE_ENVis set before React loads (static imports are hoisted past statements in ESM)BlobCacheHandlerusing deploy-scoped@netlify/blobsfor ISR cachingvinext/netlifypackage exportCo-Authored-By: Claude Opus 4.6 noreply@anthropic.com