Instructions for AI agents working on this codebase.
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 reimplements the Next.js API surface using Vite, with Cloudflare Workers as the primary deployment target. The goal is to let developers keep their existing Next.js code and deploy it to Workers.
pnpm test # Vitest — full suite (~2 min, serial)
pnpm test tests/routing.test.ts # Run a single test file (~seconds)
pnpm test tests/shims.test.ts tests/link.test.ts # Run specific files
pnpm run test:e2e # Playwright E2E tests (all projects, use PLAYWRIGHT_PROJECT=<name> to target one)
pnpm run typecheck # TypeScript via tsgo (fast)
pnpm run lint # oxlint
pnpm run fmt # oxfmt (format)
pnpm run fmt:check # oxfmt (check only, no writes)
pnpm run build # Build the vinext packagepackages/vinext/src/
index.ts # Main Vite plugin
cli.ts # vinext CLI
shims/ # One file per next/* module
routing/ # File-system route scanners
server/ # SSR handlers, ISR, middleware
cloudflare/ # KV cache handler
tests/
*.test.ts # Vitest tests
fixtures/ # Test apps (pages-basic, app-basic, etc.)
e2e/ # Playwright tests
examples/ # User-facing demo apps
| File | Purpose |
|---|---|
index.ts |
Vite plugin — resolves next/* imports, generates virtual modules |
shims/*.ts |
Reimplementations of next/link, next/navigation, etc. |
server/dev-server.ts |
Pages Router SSR handler |
entries/app-rsc-entry.ts |
App Router RSC entry generator |
routing/pages-router.ts |
Scans pages/ directory |
routing/app-router.ts |
Scans app/ directory |
- Check if Next.js has it — look at Next.js source to understand expected behavior
- Search the Next.js test suite — before writing code, search
test/e2e/andtest/unit/in the Next.js repo for related test files (see below) - Add tests first — put test cases in the appropriate
tests/*.test.tsfile - Implement in shims or server — most features are either a shim (
next/*module) or server-side logic - Add fixture pages if needed —
tests/fixtures/has test apps for integration testing - Run the relevant test file(s) to verify your changes (see Running Tests below)
This is a required step for all feature work and bug fixes. Before writing code, search the Next.js repo's test/e2e/ and test/unit/ directories for tests related to whatever you're working on. Search broadly, not just for exact feature names.
For example, when working on middleware:
- Search for
middlewareandproxyin test directory names - Search for error messages like
"must export"to find validation tests - Check for edge cases like missing exports, misspelled names, invalid configs
Why this matters: vinext aims to match Next.js behavior exactly. If Next.js has a test for it, we should have an equivalent test. Missing this step has caused silent behavioral differences, like middleware failing open on invalid exports instead of throwing an error (which Next.js tests explicitly).
When you find relevant Next.js tests, port the test cases to our test suite and include a comment linking back to the original Next.js test file:
// Ported from Next.js: test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.tsUse gh search code for efficient searching:
gh search code "middleware" --repo vercel/next.js --filename "*.test.*" --limit 20
gh search code "must export" --repo vercel/next.js --filename "*.test.*" --limit 10Always run targeted tests, not the full suite. The full Vitest suite takes ~2 minutes because test files run serially (to avoid Vite deps optimizer cache races). Running the full suite during development wastes time, especially when multiple agents are working on the repo simultaneously.
During development, run only the test file(s) relevant to your change:
# Run a single test file (fast — seconds, not minutes)
pnpm test tests/routing.test.ts
# Run a few related files
pnpm test tests/shims.test.ts tests/link.test.ts
# Run all nextjs-compat tests
pnpm test tests/nextjs-compat/
# Run tests matching a name pattern
pnpm test -t "middleware"Which test files to run depends on what you changed:
| If you changed... | Run these tests |
|---|---|
A shim (shims/*.ts) |
tests/shims.test.ts + the specific shim test (e.g., tests/link.test.ts) |
Routing (routing/*.ts) |
tests/routing.test.ts, tests/route-sorting.test.ts |
App Router server (entries/app-rsc-entry.ts) |
tests/app-router.test.ts, tests/features.test.ts |
Pages Router server (server/dev-server.ts) |
tests/pages-router.test.ts |
| Caching/ISR | tests/isr-cache.test.ts, tests/fetch-cache.test.ts, tests/kv-cache-handler.test.ts |
| Build/deploy | tests/deploy.test.ts, tests/build-optimization.test.ts |
| Next.js compat features | tests/nextjs-compat/ (the relevant file) |
Let CI run the full suite. The full pnpm test and all 5 Playwright E2E projects run in CI on every PR. You do not need to run the full suite locally before pushing. CI will catch any cross-cutting regressions.
When to run the full suite locally: Only if you're making a broad change that touches shared infrastructure (e.g., the Vite plugin's resolveId hook, virtual module generation, or the test helpers themselves). Even then, consider pushing and letting CI do it.
Always check dev and prod server parity. Request handling logic exists in multiple places that must stay in sync:
entries/app-rsc-entry.ts— App Router dev (generates the RSC entry)server/dev-server.ts— Pages Router devserver/prod-server.ts— Pages Router production (handles middleware, routing, SSR directly)cloudflare/worker-entry.ts— Cloudflare Workers entry
The App Router production server delegates to the built RSC entry, so it inherits fixes from entries/app-rsc-entry.ts. But the Pages Router production server has its own middleware/routing/SSR logic that must be updated separately.
When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as "follow-ups" — fix them in the same PR.
- Dev server logs: Run
npx vite devin a fixture directory - RSC streaming issues: Context is often cleared before stream consumption — check AsyncLocalStorage usage
- Module resolution: Vite has separate module instances for RSC/SSR/client environments
tests/fixtures/pages-basic/— Pages Router test apptests/fixtures/app-basic/— App Router test appexamples/app-router-cloudflare/— App Router on Workersexamples/pages-router-cloudflare/— Pages Router on Workers
Add new test pages to fixtures, not to examples. Examples are for user-facing demos.
The examples/ directory contains real-world Next.js apps ported to run on vinext. These are deployed to Cloudflare Workers on every push to main (see .github/workflows/deploy-examples.yml).
| Example | Type | URL |
|---|---|---|
app-router-cloudflare |
App Router basics | app-router-cloudflare.vinext.workers.dev |
pages-router-cloudflare |
Pages Router basics | pages-router-cloudflare.vinext.workers.dev |
app-router-playground |
Next.js playground (MDX, Tailwind) | app-router-playground.vinext.workers.dev |
realworld-api-rest |
RealWorld spec (Pages Router) | realworld-api-rest.vinext.workers.dev |
nextra-docs-template |
Nextra docs site (MDX, App Router) | nextra-docs-template.vinext.workers.dev |
benchmarks |
Performance benchmarks | benchmarks.vinext.workers.dev |
hackernews |
HN clone (App Router, RSC) | hackernews.vinext.workers.dev |
- Create a directory under
examples/with apackage.json(use"vinext": "workspace:*") - Add a
vite.config.tswithvinext()andcloudflare()plugins - Add a
wrangler.jsonc— for simple apps use"main": "vinext/server/app-router-entry"(no custom worker entry needed) - Add the example to the deploy matrix in
.github/workflows/deploy-examples.yml:- Add to
matrix.examplearray (withname,project,wrangler_config) - Add to the
examplesarray in the PR comment step
- Add to
- Add a smoke test entry in
scripts/smoke-test.sh— add a line to theCHECKSarray:"your-example-name / expected-text-in-body" - Run
./scripts/smoke-test.shlocally to verify after deploying
scripts/smoke-test.sh is a lightweight post-deploy check that curls every deployed example and verifies HTTP 200 + expected content. It runs automatically in CI after the deploy job completes.
./scripts/smoke-test.sh # check production URLs
./scripts/smoke-test.sh --preview pr-42 # check PR preview URLsWhen adding a new example, always add a corresponding smoke test entry. The format is:
"worker-name /path expected-text"
where expected-text is a case-insensitive string that must appear in the response body.
The examples in .github/repos.json are the ecosystem of Next.js apps we want to support. When porting one:
- Use App Router unless the original app specifically requires Pages Router
- Keep the same content — the goal is to prove the app works on vinext, not to rewrite it
- Use
@mdx-js/rollupfor MDX support (vinext auto-detects and injects it, or you can register it manually invite.config.ts) - File issues for anything that requires workarounds — missing shims, unsupported config options, etc.
- Don't depend on the original framework's build plugins — e.g., Nextra's webpack plugin won't work; port the content and build a lightweight equivalent
Context7 provides fast access to up-to-date documentation and source code for libraries. Use it liberally when researching how to implement something or debugging behavior.
Key library IDs for this project:
/vercel/next.js— Next.js source code and docs/llmstxt/nextjs_llms_txt— Extended Next.js documentation/vitejs/vite-plugin-react— Vite RSC plugin docs
Example queries:
- How Next.js implements
headers()andcookies()internally - AsyncLocalStorage patterns for request-scoped context
- RSC streaming and rendering lifecycle
- Route matching and middleware patterns
Use EXA for web search when you need to find recent discussions, blog posts, GitHub issues, or documentation that isn't in Context7. Particularly useful for:
- Finding workarounds for edge cases
- Understanding how other frameworks solved similar problems
- Locating relevant GitHub issues and discussions
When in doubt, look at how Next.js does it. Vinext aims to replicate Next.js behavior, so their implementation is the authoritative reference.
If you're trying to understand how something works under the hood — route matching, RSC streaming, caching behavior, API semantics — the best approach is to go look at the Next.js source code and understand what they're doing, then apply it to how we do things in this project.
Always use Node.js built-in modules and APIs before reaching for third-party packages or hand-rolling your own implementation. Node ships a lot of useful utilities that people forget about or don't know exist. Examples:
node:utilparseEnvfor parsing.envfile contents (notdotenv)node:cryptorandomUUID()for UUIDs (notuuid)node:fs/promisesfor async file operationsnode:testpatterns when they applyURLandURLSearchParamsfor URL manipulation (not string splitting)structuredClonefor deep cloning (notlodash.cloneDeep)
If a Node built-in does the job, use it. Only reach for a dependency when the built-in is genuinely insufficient.
-
NEVER push directly to main. Always create a feature branch and open a PR, even for small fixes. This ensures CI runs before changes are merged and provides a review checkpoint.
-
Branch protection is enabled on main. Required checks: Format, Lint, Typecheck, Vitest, Playwright E2E. Pushing directly to main bypasses these protections and can introduce regressions.
-
NEVER use
gh pr merge --admin. The--adminflag bypasses branch protection checks entirely. If merge is blocked, investigate why — don't force it through. A blocked merge usually means a required check failed or is still running. -
PR workflow:
- Create a branch:
git checkout -b fix/descriptive-name - Make changes and commit
- Push branch:
git push -u origin fix/descriptive-name - Open PR via
gh pr create - Wait for CI to pass — all required checks (Format, Lint, Typecheck, Vitest, Playwright E2E) must be green
- Merge via
gh pr merge --squash --delete-branch - If merge is blocked, check which status check failed and fix it — do not bypass with
--admin
- Create a branch:
CI is split into safe checks (no secrets) and deploy previews (requires secrets). This lets external contributors get feedback on their PRs without exposing credentials.
Safe CI (ci.yml) runs for all PRs after first-time contributor approval:
- Format, Lint, Typecheck, Vitest, Playwright E2E
- Uses zero secrets and read-only permissions
- First-time contributors need one manual approval, then subsequent PRs run automatically
Deploy previews (deploy-examples.yml) run automatically only for same-repo branches:
- The entire workflow is skipped for fork PRs via a job-level
ifcondition - Cloudflare employees should push branches to the main repo (not fork), so previews deploy automatically
- For fork PRs, a maintainer can comment
/deploy-previewto trigger the deploy (seedeploy-preview-command.yml)
/deploy-preview slash command (deploy-preview-command.yml):
- Triggered by commenting
/deploy-previewon any PR - Restricted to org members, collaborators, and repo owners via
author_association - Builds all examples, deploys previews, runs smoke tests, and posts preview URLs
When modifying CI workflows, keep these rules in mind:
ci.ymlmust never use secrets. It runs untrusted code from forks.deploy-examples.ymlmust skip entirely for fork PRs. Don't remove the job-levelifguard.- The
/deploy-previewslash command gates secret usage behind theauthor_associationcheck.
Non-obvious patterns and pitfalls discovered during development. Read this before making significant changes.
This is the single most important architectural detail. The RSC environment and the SSR environment are separate Vite module graphs with separate module instances. If you set state in a module in the RSC environment (e.g., setNavigationContext() in next/navigation), the SSR environment's copy of that module is unaffected.
Per-request state (pathname, searchParams, params, headers, cookies) must be explicitly passed from the RSC entry to the SSR entry via the handleSsr(rscStream, navContext) call. The SSR entry calls the setter before rendering and cleans up afterward.
Rule of thumb: Any per-request state that "use client" components need during SSR must be passed across the environment boundary. They don't share module state.
The RSC plugin handles:
- Bundler transforms for
"use client"/"use server"directives - RSC stream serialization (wraps
react-server-dom-webpack) - Multi-environment builds (RSC/SSR/Client)
- CSS code-splitting and auto-injection
- HMR for server components
- Bootstrap script injection for client hydration
vinext handles everything else:
- File-system routing (scanning
app/andpages/directories) - Request lifecycle (middleware, headers, redirects, rewrites, then route handling)
- Layout nesting and React tree construction
- Client-side navigation and prefetching
- Caching (ISR,
"use cache", fetch cache) - All
next/*module shims
The RSC entry's default export is the request handler. The plugin calls it for every request; vinext does route matching, builds the React tree, renders to RSC stream, and delegates to the SSR entry for HTML.
You must use createBuilder() + builder.buildApp() for production builds, not build() directly. Calling build() from the Vite JS API doesn't trigger the RSC plugin's multi-environment build pipeline. buildApp() runs the 5-step RSC/SSR/client build sequence in the correct order.
- Build-time root prefix: Vite prefixes virtual module IDs with the project root path when resolving SSR build entries. The
resolveIdhook must handle bothvirtual:vinext-server-entryand<root>/virtual:vinext-server-entry. \0prefix in client environment: When the RSC plugin generates its browser entry, it imports virtual modules using the already-resolved\0-prefixed ID. Vite'simport-analysisplugin can't resolve this. Fix: strip the\0prefix before matching inresolveId.- Absolute paths required: Virtual modules have no real file location, so all imports within them must use absolute paths.
Next.js 15 changed params and searchParams to Promises. For backward compatibility with pre-15 code, vinext creates "thenable objects":
Object.assign(Promise.resolve(params), params);This works both as await params (new style) and params.id (old style). The same pattern applies to generateMetadata and generateViewport.
The ISR cache layer sits above CacheHandler, not inside it. CacheHandler (matching Next.js 16's interface) is a simple key-value store. ISR semantics live in a separate isr-cache.ts module:
- Stale-while-revalidate: Returns stale entries (not null) while background regeneration runs
- Dedup: A
Map<string, Promise>keyed by cache key ensures only one regeneration per key at a time - Revalidate tracking: A side map stores revalidate durations by cache key (populated on MISS, read on HIT/STALE)
- Tag invalidation: Tag-invalidated entries are hard-deleted (return null), unlike time-expired entries which return stale
The caching layer is pluggable via setCacheHandler(). KV is the default for Cloudflare Workers. The ISR logic works automatically with any backend.
When adding support for third-party Next.js libraries:
next/navigation.js(with .js extension): Libraries likenuqsimport with the.jsextension. Vite'sresolve.aliasdoes exact matching, so aresolveIdhook strips.jsfromnext/*imports and redirects through the shim map.- next-themes: Works out of the box. ThemeProvider,
useTheme, SSR script injection all function correctly. - next-intl: Requires deep integration. It expects a plugin from
next.config.ts(createNextIntlPlugin) that injects config at build time. Simply installing and importing doesn't work. - General pattern: Libraries that only import from
next/*public APIs tend to work. Libraries that depend on Next.js build plugins or internal APIs need custom shimming.