Skip to content

Search the guides, deployment pages, and API reference.

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 pitlane
yarn
yarn add pitlane
pnpm
pnpm add pitlane
bun
bun add pitlane
deno
deno add npm:pitlane
vp
vp add pitlane
vlt
vlt add pitlane
nub
nub add pitlane

The 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-plugin
yarn
yarn add -D @netlify/vite-plugin
pnpm
pnpm add -D @netlify/vite-plugin
bun
bun add -D @netlify/vite-plugin
deno
deno add -D npm:@netlify/vite-plugin
vp
vp add -D @netlify/vite-plugin
vlt
vlt add -D @netlify/vite-plugin
nub
nub add -D @netlify/vite-plugin
vite.config.ts
import { 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:

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:

netlify/edge-functions/server.mjs
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:crypto beyond 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:

netlify.toml
[build]
publish = "dist/client"

Local development

Terminal window
vp dev # dev server + Netlify platform emulation
vp build # production build
vp preview # serve the production build via the pitlane/vite-plugin-remix preview

Running 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:

Terminal window
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).

.github/workflows/deploy.yml
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.

index.html
<!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>
app/main.tsx
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 />);
tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component",
},
}
vite.config.ts
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:

netlify.toml
[build]
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

Deploys are unchanged: vpx netlify-cli deploy --no-build --prod from the CLI, or the same GitHub Actions workflow above.