Netlify
Deploy a Remix 3 app to Netlify by publishing the client build as static files and serving everything else through one Netlify Function that wraps the built fetch handler. @netlify/vite-plugin adds local emulation of the Netlify platform (Functions, Blobs, redirects, headers, environment variables, Image CDN) to vite dev.
Configuration
The examples import from pitlane/* paths, which come from the pitlane package. The netlify template depends on scoped packages such as @pitlane/vite-plugin-remix, @pitlane/assets, and @pitlane/theme, so add pitlane before switching its imports. Put it in dependencies when app code imports runtime subpaths such as pitlane/assets:
npm
npm add pitlaneyarn
yarn add pitlanepnpm
pnpm add pitlanebun
bun add pitlanedeno
deno add npm:pitlanevp
vp add pitlanevlt
vlt add pitlanenub
nub add pitlaneThe Pitlane package guide lists the remix and vite versions it requires and the standalone scoped packages. Then install the Netlify plugin:
npm
npm add -D @netlify/vite-pluginyarn
yarn add -D @netlify/vite-pluginpnpm
pnpm add -D @netlify/vite-pluginbun
bun add -D @netlify/vite-plugindeno
deno add -D npm:@netlify/vite-pluginvp
vp add -D @netlify/vite-pluginvlt
vlt add -D @netlify/vite-pluginnub
nub add -D @netlify/vite-pluginimport { remix } from "pitlane/vite-plugin-remix";
import netlify from "@netlify/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix(), netlify()],
});The server function
Netlify runs server code two ways, and both speak the same fetch-handler language as the built server entry. The wrapper is three lines either way. Pick one:
| Netlify Functions | Netlify Edge Functions | |
|---|---|---|
| Runtime | Node, in Netlify's serverless infrastructure | Deno, at the edge PoP nearest the visitor |
| Static assets | CDN wins first via preferStatic |
Run before static serving, so assets must be excluded via excludedPath |
| Fits | The default; larger CPU/memory limits, Node APIs | Latency-sensitive SSR; stricter runtime limits |
Netlify Functions (Node)
Create netlify/functions/server.mjs:
import server from "../../dist/ssr/index.js";
export default request => server.fetch(request);
export const config = {
path: "/*",
preferStatic: true,
};path: "/*" routes every request to the function; preferStatic: true lets files in the publish directory win first, so hashed client assets are served from the CDN and never touch the function.
Netlify Edge Functions (Deno)
Create netlify/edge-functions/server.mjs instead:
import server from "../../dist/ssr/index.js";
export default request => server.fetch(request);
export const config = {
path: "/*",
excludedPath: ["/assets/*", "/favicon.ico"],
};Edge functions run before the CDN serves static files, so there is no preferStatic. Instead, excludedPath carves the static surface out of the match. /assets/* covers the hashed client build. Extend the array with anything else you serve from public/.
The edge variant has its own rules:
- The handler executes on Deno. Remix 3 is built on Web APIs, so the SSR bundle runs there. But avoid Node-only APIs (
node:fs,node:cryptobeyond WebCrypto) in server code you deploy to the edge. - Don't deploy both files: the edge function matches first and the Node function would never run.
Either way, netlify.toml names the publish directory and deliberately sets no build command. The Vite build always runs in your CI (or your shell), never on Netlify's build system, so the fingerprinted assets you tested are the ones that reach production:
[build]
publish = "dist/client"Local development
vp dev # dev server + Netlify platform emulation
vp build # production build
vp preview # serve the production build via the pitlane/vite-plugin-remix previewRunning under the Netlify CLI instead? netlify dev configures the environment itself and the Vite plugin steps aside automatically.
Deploy with the CLI
Create the site once and link the directory, then every deploy is two commands:
vpx netlify-cli login
vpx netlify-cli init # create + link the site (or `link` for an existing one)
vp build
vpx netlify-cli deploy --no-build # draft URL
vpx netlify-cli deploy --no-build --prod # production--no-build keeps the CLI from triggering a build of its own (you already built). The deploy uploads dist/client and bundles whichever server wrapper you committed. The Node function goes through Netlify's function bundler, the edge function through the Deno-based edge bundler. Both trace the import of dist/ssr/index.js and package it automatically, locally in the CLI.
Deploy with GitHub Actions
Store two repository secrets: NETLIFY_AUTH_TOKEN (a personal access token) and NETLIFY_SITE_ID (Site configuration → Site details → Site ID, or netlify sites:list).
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
deployments: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1.21.1
with:
cache: true
- run: vp install --frozen-lockfile
- run: vp build
- run: vpx netlify-cli deploy --no-build --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}Environment variables
Set them in the Netlify UI (Site configuration → Environment variables) or via netlify env:set. Netlify Functions read them as process.env.*. Edge Functions use Netlify.env.get("KEY"). The Vite plugin mirrors them into vite dev.
Client-only apps
A client-only Remix 3 app runs remix() in SPA mode: server: false builds no server environment, so there is no server function to write. The plugin's remaining job is component HMR, so it is worth keeping in a static build.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Remix App</title>
<script type="module" src="/app/main.tsx"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>import { createRoot, on, type Handle } from "remix/component";
function App(handle: Handle) {
let count = 0;
return () => (
<button
mix={[
on("click", () => {
count++;
handle.update();
}),
]}
>
Count: {count}
</button>
);
}
createRoot(document.getElementById("app")!).render(<App />);{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component",
},
}import { remix } from "pitlane/vite-plugin-remix";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix({ server: false })],
});vite build emits the site into dist/. Publish that directory and add the SPA fallback so deep links serve index.html:
[build]
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200Deploys are unchanged: vpx netlify-cli deploy --no-build --prod from the CLI, or the same GitHub Actions workflow above.