npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pitlane/dev

v0.6.1

Published

remix() — the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server with component and server-data HMR, SPA mode, build-time prerendering, and preview for any Vite or Vite+ project.

Downloads

1,430

Readme

@pitlane/dev

The remix() Vite plugin for Remix 3. One plugin wires a Remix app into any Vite or Vite+ project: multi-environment build orchestration, the clientEntry() hydration transform, dev serving through your app's fetch handler, and a preview server for the production build.

@pitlane/dev is deliberately platform-agnostic. Your server entry default-exports a standard fetch handler, and hosting composes around it — platform plugins (@cloudflare/vite-plugin, @netlify/vite-plugin, nitro/vite) in the same plugin array, or plain runtimes (Node, Bun, Deno) running the built output directly. Composable hosting, not a hosting engine.

Install

npm install --save-dev @pitlane/dev
# or
vp add -D @pitlane/dev

Requires remix@^3.0.0-rc.1 and vite@>=7 as peers. Tested against Vite 8.1 (Rolldown), Vite+ 0.2 (vp), and [email protected] — the templates are the continuously tested reference.

Quick start

// vite.config.ts
import { remix } from "@pitlane/dev";
import { defineConfig } from "vite"; // or "vite-plus"

export default defineConfig({
    plugins: [remix()],
});
// app/entry.server.tsx
import { staticFiles } from "remix/middleware/static";
import { createRouter, type MiddlewareContext } from "remix/router";

import { Document } from "./document.tsx";
import { render, type RenderMiddleware } from "./render.tsx";
import { routes } from "./routes.ts";

type AppContext = MiddlewareContext<[RenderMiddleware]>;

declare module "remix/router" {
    interface RouterTypes {
        context: AppContext;
    }
}

export let router = createRouter<AppContext>({
    middleware: [staticFiles("./dist/client"), render()],
});

router.map(routes.home, ({ render }) => render(<Document />));

export default router;

if (import.meta.hot) {
    import.meta.hot.accept();
}
// app/entry.browser.ts
import { run } from "remix/ui";

run({
    async loadModule(moduleUrl, exportName) {
        let mod = await import(/* @vite-ignore */ moduleUrl);
        return mod[exportName];
    },
});

vite dev serves the app through your router. vite build produces dist/ssr and dist/client. vite preview serves the production build through the same fetch handler production runs.

Hot module replacement

vite dev hot-updates both halves of a Remix app in place, keeping live client state. Full details, including which edits preserve state and which remount, are in the HMR guide.

Components. Editing a component swaps its new code in without remounting, so hydrated clientEntry() islands keep their state (open menus, form input, counters). This runs the remix/ui-hmr transforms during dev. Both authoring styles hot-swap, because @pitlane/dev normalizes arrow-form component and clientEntry() exports to named functions before instrumenting them:

// All of these hot-swap in place, preserving live state:
export const Counter = clientEntry(import.meta.url, handle => {
    /* ... */
});
export const Toggle = clientEntry(import.meta.url, function Toggle(handle) {
    /* ... */
});
export const Card = handle => () => <div />;
export function Panel(handle) {
    /* ... */
}

Only named (PascalCase) component exports in .tsx/.jsx files whose setup returns a render function are instrumented; other exports are left untouched. Editing the render function keeps live state. Editing the setup scope above the return remounts the component, so its state resets.

Server data. Editing a server-only module (the document, a middleware, a route handler, any module the client never imports) re-fetches the current page through your fetch handler and reconciles the new server-rendered HTML into the DOM. Hydrated island state survives, so you see fresh server output without a full-page reload. This is the Remix 3 analog of React Router's loader/action revalidation, driven through the frame runtime rather than a client data router.

It needs one line in your document:

import { HMR } from "pitlane:dev";

// ...
<body>
    <HMR />
    {/* ... */}
</body>;

<HMR /> is a hydrated island, so it has a component handle, and it revalidates with handle.frames.top.reload(). Remix hands the top frame to components only, which is why this is a component rather than something the plugin injects. Reloading the frame produces no history entry and fires no navigate event, so apps that intercept navigation themselves work unchanged.

Leave it unguarded: in a production build the specifier resolves to a component that renders nothing and carries no client code. Apps with clientEntry: false have nothing to hydrate it, so it stays inert there too. See the HMR guide.

Options

remix({
    server: true, // default — false selects SPA mode
    prerender: undefined, // default — true, a path array, a function, or a config object
    clientEntry: "app/entry.browser", // default — false disables the client build
    serverEntry: "app/entry.server", // default
    serverEnvironments: ["ssr"], // default
    serverHandler: true, // default — false when a platform plugin serves dev requests
});

| Option | Type | Default | Purpose | | -------------------- | ---------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | server | boolean | true | Whether the app has a server. Pass false for SPA mode, which ignores every option below. | | prerender | boolean \| string[] \| fn \| obj | none | Render paths to static HTML at build time. See Prerendering. | | clientEntry | string \| false | "app/entry.browser" | Client entry module. Pass false for fully server-rendered apps with no hydration. | | serverEntry | string | "app/entry.server" | Server entry module, built as dist/ssr/index.js. | | serverEnvironments | string[] | ["ssr"] | Environment names the clientEntry() transform treats as "server". | | serverHandler | boolean | true | Serve dev requests through your server entry. Set false when @cloudflare/vite-plugin or nitro/vite owns dev-time request handling. Netlify's plugin does not serve SSR, so keep the default there. |

Prerendering

prerender renders paths to static HTML during vite build and writes them into the client output, so a CDN answers those URLs and the server never sees them. There is no second rendering path: the build sends a Request through the same fetch handler production runs.

remix({ prerender: ["/", "/blog", "/blog/hello-world"] });

true prerenders every static path in the app's route map, which the server entry exports alongside its handler:

// app/entry.server.tsx
export { routes } from "./routes.ts";
export default router;

A function computes the list, and gets getStaticPaths() for the route-map half of it:

remix({
    async prerender({ getStaticPaths }) {
        let slugs = await getPostSlugsFromCMS();
        return [...getStaticPaths(), ...slugs.map(slug => `/blog/${slug}`)];
    },
});

The object form adds concurrency, and spider for following the links each rendered page contains:

remix({ prerender: { paths: ["/"], spider: true, concurrency: 4 } });

Each path lands at <path>/index.html under the client output. Rendering runs after both builds and the assets manifest, so the HTML names real hashed chunks. Unsupported with server: false, which builds no server to render with.

A bundle Node cannot import renders anyway: @cloudflare/vite-plugin and friends already contribute a preview server, so when the import fails the build starts that server and renders through it, inside the runtime that will serve the pages. Nothing extra to configure.

Full details in the prerendering guide; the crawler underneath is @pitlane/crawler, and the crawling guide covers using it on its own.

SPA mode

Some apps have no server — a static host, a router that never touches one. remix({ server: false }) targets those. React Router spells the same switch ssr: false:

// vite.config.ts
import { remix } from "@pitlane/dev";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [remix({ server: false })],
});

There is no server environment, nothing is built to dist/ssr, and vite build emits a static site from your index.html. The plugin's one remaining job is the one a SPA still wants: component HMR. Editing a component swaps it in place and keeps live state, arrow forms included.

Every server* option goes with it, and clientEntry too — the browser entry is whatever index.html loads. <HMR /> from pitlane:dev resolves to the inert component, because there is no server data to revalidate.

Deploying means pointing every unknown URL at index.html so the client router can resolve it; on GitHub Pages that is a copy of index.html at 404.html, on Netlify a /* /index.html 200 redirect.

The option removes the server, not the server rendering, which is what React Router's ssr: false does too. For a browser-rendered UI in front of routes that still run per request, stay in the default mode and let the server entry answer JSON on its data routes and one remix/ui shell on its document routes: nothing here asks it to render app UI. Client rendering with a server shows the shape.

SPA mode also works under Vite's experimental bundled dev mode (experimental.bundledDev, or vite dev --experimentalBundle), component hot-swap included. Server-rendered apps do not yet: bundled dev serves only bundle entrypoints, so the client module URLs an SSR render writes into its HTML resolve to nothing. That is upstream's Phase 4 — server environments — still a prototype.

The server entry contract

The server entry default-exports a fetch handler — an object exposing fetch(request: Request): Response | Promise<Response>. A createRouter() router already is one:

export default router;

Every consumer speaks that same shape:

  • Dev imports the entry through Vite's module runner and calls default.fetch.
  • Preview imports dist/ssr/index.js and calls default.fetch.
  • Production is whatever your target does with a fetch handler: export default { fetch: router.fetch } on Workers, Bun.serve({ fetch: router.fetch }), deno serve dist/ssr/index.js, or Node via remix/node-fetch-server.

Need extra worker exports? Wrap it:

export default {
    fetch: router.fetch,
    async queue(batch) {
        /* ... */
    },
};

Asset references — ?assets=

Server-rendered documents need the hashed URLs of client assets. Import any module with the ?assets= query to get its resolved assets for an environment:

// app/document.tsx
import { mergeAssets } from "@pitlane/dev/runtime";

import clientAssets from "./entry.browser.ts?assets=client";
import serverAssets from "./entry.server.tsx?assets=ssr";

export function Document() {
    let assets = mergeAssets(clientAssets, serverAssets);

    return () => (
        <html lang="en">
            <head>
                {assets.css.map(attrs => (
                    <link key={attrs.href} {...attrs} rel="stylesheet" />
                ))}
                <script async src={clientAssets.entry} type="module" />
                {assets.js.map(attrs => (
                    <link key={attrs.href} {...attrs} rel="modulepreload" />
                ))}
            </head>
            <body>{/* ... */}</body>
        </html>
    );
}

Each result is { entry?, js: [{ href }], css: [{ href }] }; mergeAssets dedupes by href. In dev, URLs point at source modules and js is empty (Vite handles module loading); in production they point at hashed files in dist/client.

Type the query imports by adding the ambient declarations to your tsconfig:

{ "compilerOptions": { "types": ["@pitlane/dev/assets"] } }

clientEntry() authoring rules

The transform rewrites clientEntry(import.meta.url, …) so the first argument becomes the module's asset URL plus an #ExportName fragment — on the server via the ?assets=client manifest, on the client via import.meta.url itself.

import { clientEntry, on } from "remix/ui";

export const Counter = clientEntry(import.meta.url, handle => {
    let count = 0;
    return () => (
        <button
            mix={[
                on("click", () => {
                    count++;
                    handle.update();
                }),
            ]}
        >
            Count: <span>{count}</span>
        </button>
    );
});

The matched pattern is strict, by design:

  • Named, top-level exports onlyexport const Name = clientEntry(import.meta.url, …). The #Name fragment comes from the export name.
  • Default exports, aliased imports of clientEntry, and non-exported calls are left untouched.
  • Multiple clientEntry exports per file share one assets import.

Deployment

The client build and component authoring never change across targets. Only two things vary: the serverHandler option and how production runs the built fetch handler.

Node

// server.ts
import * as http from "node:http";
import { createRequestListener } from "remix/node-fetch-server";

// @ts-expect-error - built output has no types
import ssr from "./dist/ssr/index.js";

let server = http.createServer(createRequestListener(request => ssr.fetch(request)));
server.listen(process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000);

Static assets are served by the staticFiles("./dist/client") middleware inside your router, so server.ts stays a one-liner and preview/production share one code path.

Bun

// server.ts
import router from "./app/entry.server.tsx";

Bun.serve({
    port: 3000,
    fetch: request => router.fetch(request),
});

Deno

The built entry already satisfies deno serve's default-export contract:

deno serve --allow-read --allow-net dist/ssr/index.js

Cloudflare Workers

// vite.config.ts
import { remix } from "@pitlane/dev";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [remix({ serverHandler: false }), cloudflare({ viteEnvironment: { name: "ssr" } })],
});
// wrangler.jsonc
{
    "name": "my-remix-app",
    "main": "app/entry.server.tsx",
    "assets": { "directory": "dist/client" },
    "compatibility_date": "2026-04-02",
    "compatibility_flags": ["nodejs_compat"],
}

vite dev runs your server code inside workerd (real bindings, real runtime), vite preview serves the production build through Miniflare, and wrangler deploy ships it.

Netlify

Keep the defaults — Netlify's plugin emulates the platform in dev while your fetch handler serves SSR. A three-line Netlify Function (netlify/functions/server.mjs) wraps the built entry; see the Netlify guide.

export default defineConfig({
    plugins: [remix(), netlify()],
});

Vercel (via Nitro)

import { nitro } from "nitro/vite";

export default defineConfig({
    plugins: [remix({ serverHandler: false }), nitro()],
});

Build layout

dist/
├── client/          # static assets, hashed — serve as-is
│   └── assets/*
└── ssr/
    └── index.js     # your fetch handler, bundled

vite build builds the SSR environment first, then the client (the client build resolves asset references against the SSR manifest). When another plugin also orchestrates builds — Cloudflare's, for example — remix() coordinates so each environment builds exactly once.

Compatibility

| Dependency | Tested against | | ----------- | -------------- | | vite | 8.1.5 | | vite-plus | 0.2.6 | | remix | 3.0.0-rc.2 | | Node | 24 LTS, 26 |

Remix 3 is in prerelease; each @pitlane/dev release records the exact prerelease it was verified against. Rolldown is not required — the transform runs identically on generic Vite and Vite+.

Troubleshooting

AssertionError: isRunnableDevEnvironment(environment) on vite dev — your project resolves two different vite packages (typically Vite+ running the server while a plain vite install satisfies peer ranges). Give the project a single vite identity by aliasing, e.g. with pnpm:

// package.json
{
    "devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" },
    "pnpm": { "overrides": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" } },
}

Generic-Vite projects have one vite by construction and are unaffected.

License

MIT