Skip to content

feat(platform)!: migrate to nitro/vite and split analog() into analog() + angular() + nitro()#2343

Merged
brandonroberts merged 65 commits into
alphafrom
feat/nitro-vite-plugin
May 26, 2026
Merged

feat(platform)!: migrate to nitro/vite and split analog() into analog() + angular() + nitro()#2343
brandonroberts merged 65 commits into
alphafrom
feat/nitro-vite-plugin

Conversation

@brandonroberts

Copy link
Copy Markdown
Member

PR Checklist

Migrates Analog to Nitro v3's first-party Vite plugin (nitro/vite) and reshapes @analogjs/platform's plugin surface. analog() no longer composes @analogjs/vite-plugin-angular or Nitro internally — users wire analog() + angular() + nitro() as three explicit plugins.

Closes #2035

Affected scope

  • Primary scope: platform
  • Secondary scopes: vite-plugin-angular, vite-plugin-nitro, create-analog

Recommended merge strategy for maintainer [optional]

  • Squash merge
  • Rebase merge
  • Other

What is the new behavior?

Plugin chain is now explicit (breaking)

analog() used to invoke @analogjs/vite-plugin-angular and @analogjs/vite-plugin-nitro internally. v3 splits the chain into three plugins users compose themselves:

import analog from '@analogjs/platform';
import angular from '@analogjs/vite-plugin-angular';
import { nitro } from 'nitro/vite';

export default defineConfig(() => ({
  plugins: [analog(), angular(), nitro()],
}));
  • @analogjs/platform owns its own Nitro orchestration through nitro/vite directly (via a new analogNitroPlugin registered as a Nitro module).
  • @analogjs/vite-plugin-nitro continues to ship as a standalone package (orchestration helpers were copied into platform — the two evolve independently).

Architect builder shims for the platform vite executor

  • @analogjs/vite-plugin-angular publishes ./builders/vite and ./builders/vite-dev-server subpath exports.
  • @analogjs/platform ships local Architect-builder shims that import + re-export from those subpaths (storybook-angular pattern). Drops the vite: "@analogjs/vite-plugin-angular:vite" string redirect from builders.json so Architect lands on a real local file on first lookup.
  • Dropped the broken Nx vite executor that forwarded to @nx/vite's viteBuildExecutor (it never called buildApp()).

Output layout preserved

Nitro's final output paths are pinned in analogNitroPlugin.setup() to the legacy dist/<rootDir>/analog/{server,public,nitro.json} layout for build only. Dev mode keeps Nitro defaults so the dev server's readAsset doesn't crash on the unbuilt dist/. buildDir stays at Nitro's default so Rolldown's prerender rebundle finds workspace deps in node_modules/.

Platform owns the server.fs.allow workaround

Vite 8's strict fs would otherwise reject nitro/vite's env-runner load of its own dev-entry.mjs (a pnpm content-hash path). analogNitroPlugin.config() now contributes server.fs.allow: [workspaceRoot] so user vite.config.ts files don't carry the workaround.

ng-update schematic for plugin separation

@analogjs/platform's ng-update migration migrate-to-separated-plugins:

  • Detects legacy analog({ ... }) shape via TypeScript AST traversal.
  • Rewrites analog({ vite: {...}, nitro: {...}, ... }) into analog({...}), angular({...}), nitro({...}), lifting vite/nitro keys into their owning plugin while preserving remaining analog options.
  • Rewrites argumentless analog() into analog(), angular(), nitro().
  • Adds @analogjs/vite-plugin-angular to devDependencies (version mirrors @analogjs/platform's pin).
  • Tests assert mirroring behavior, not a specific release line.

Templates updated

  • create-analog's latest, blog, and minimal templates use the separated plugin shape.
  • nitro is injected only for pnpm scaffolds (addPnpmDependencies) since npm/yarn auto-hoist it as a transitive of @analogjs/platform; pnpm's strict isolation needs the top-level declaration.

Other notable changes

  • Demo apps (analog-app, blog-app, opt-catchall-app, tailwind-debug-app, tanstack-query-app) all on @analogjs/platform:vite builder.
  • discoverLibraryRoutes + pageGlobs helpers re-exported from @analogjs/platform (single call, fed to both analog() and angular()).
  • Options.useAPIMiddleware and Options.ssrBuildDir dropped — both were unused; the api-middleware fallback is being removed in v3 anyway.
  • Migration guide section added: apps/docs-app/docs/guides/migrating-v2-to-v3.md.
  • nitro pin bumped to 3.0.260522-beta (latest beta).
  • Per-app server.fs.allow workaround removed; experimental.websocket flag gated behind process.env.VITEST in tailwind-debug-app so Vitest run doesn't crash.

Test plan

  • nx format:check
  • pnpm build (22/22 projects)
  • pnpm test (18/18 projects)
  • Manual verification:
    • pnpm nx build analog-app emits dist/apps/analog-app/analog/server/index.mjs, nitro.json, and prerendered HTML
    • node dist/apps/analog-app/analog/server/index.mjs serves SSR routes with hydration tokens, API routes with JSON, and ssr: false routes with the raw template
    • pnpm nx serve analog-app boots cleanly without user-side server.fs.allow
    • migrate-to-separated-plugins schematic: 15/15 unit tests pass

Does this PR introduce a breaking change?

  • Yes
  • No

Breaking changes (all documented in docs/guides/migrating-v2-to-v3.md with the schematic covering most cases):

  • analog() no longer composes angular() or nitro() — users must call all three.
  • @analogjs/platform v3 no longer composes @analogjs/vite-plugin-nitro internally (vpn keeps shipping standalone).
  • Options.useAPIMiddleware and Options.ssrBuildDir removed.

Other information

  • Branch is 42 commits, organized per-package scope. Squash-merge will compact them into one commit on alpha.

🤖 Generated with Claude Code

brandonroberts and others added 30 commits May 22, 2026 21:54
Relocate the Analog-specific Nitro helpers and types into
packages/platform/src/lib/nitro/ so @analogjs/platform owns them
directly:

- renderers (SSR/client renderer virtuals, server-fetch snippet)
- get-page-handlers (.server.ts discovery → Nitro event handlers)
- page-endpoints-plugin (Rollup transform for load/action)
- get-content-files (content frontmatter discovery)
- build-sitemap (sitemap.xml + hreflang helpers)
- post-rendering-hook (Nitro prerender:generate wiring)
- i18n-prerender (locale expansion + HTML lang injection)
- debug instances under analog:nitro:*
- types (Sitemap*, Prerender*, I18nPrerenderOptions, etc.)

platform/lib/options.ts and platform/lib/utils/debug.ts now import
from the local nitro/ folder. Adds the runtime deps the helpers
require (ofetch, oxc-parser, xmlbuilder2).

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds packages/platform/src/lib/nitro/angular-linker-plugin.ts: a
Rolldown transform that runs the Angular Linker
(@angular/compiler-cli/linker/babel, gated by needsLinking()) against
partially-compiled Angular npm packages.

The plugin is intended to be wired into
ssr.optimizeDeps.rolldownOptions.plugins so the SSR / nitro
environment's dep optimizer converts ɵɵngDeclare* partial declarations
into fully-compiled definitions. Without this, the server bundle would
require JIT (eval) at runtime — forbidden on workerd / edge runtimes
and unnecessary anywhere else.

Lazily loads @babel/core + @angular/compiler-cli only on the first
matching file so apps that never trigger the SSR optimizer don't pay
the cost.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…per)

Adds packages/platform/src/lib/nitro/analog-nitro-plugin.ts: the Vite
plugin with a .nitro = { setup } property that Nitro v3's first-party
Vite plugin (nitro/vite) picks up as a NitroModule.

config() hook contributes:

- experimental.vite.services.ssr.entry → synthetic absolute path that
  the plugin's own resolveId/load resolves to a generated wrapper
  module.
- environments.ssr.optimizeDeps.include — the five @angular/* packages
  — plus rolldownOptions.plugins wiring in the linker from
  #2035.

The wrapper imports the user's main.server.ts default export
(unchanged Angular renderer contract) and inlines the client
index.html as TEMPLATE, exposing { fetch(req): Response } so
nitro/vite's default SSR renderer (fetchViteEnv) works uniformly in
dev and prod.

nitro.setup(nitro) registers page handlers, scanDirs for src/server,
the page-endpoints Rollup plugin via rollup:before, prerender route
expansion (content dirs + i18n), post-rendering hooks, sitemap on
prerender:done, and HTML lang injection for i18n.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
platform-plugin.ts now imports nitro from 'nitro/vite' and the
analogNitroPlugin from the local nitro/ folder, replacing the
viteNitroPlugin call. The plugin chain returns:

  [...nitro(nitroOptions), analogNitroPlugin({ ...platformOptions, nitro: nitroOptions }), ...]

The routeRules x-analog-no-ssr header injection continues to run
against nitroOptions before forwarding, so the generated SSR service
wrapper's noSSR branch keeps working for ssr: false routes.

Spec updated to mock nitro/vite and the analog-nitro-plugin module
directly.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…analogjs/platform

The legacy 1735-line vite-plugin-nitro.ts orchestrator and its
helpers (build-server, build-ssr, dev-server-plugin, node-web-bridge,
register-dev-middleware, register-i18n-watcher, renderers, page
endpoints, content discovery, sitemap, etc.) are all deleted. Their
behavior now lives in @analogjs/platform's nitro/ module set, driven
by Nitro v3's first-party nitro/vite plugin.

vite-plugin-nitro is kept as a thin published package so existing
dependency declarations still install, but src/index.ts is now an
empty export with a @deprecated JSDoc directing consumers to migrate
to @analogjs/platform. The ./internal sub-export (used only by the
debug instances) is removed; nitro debug instances live in
@analogjs/platform's nitro/debug.

Drop @analogjs/vite-plugin-nitro from @analogjs/platform's runtime
dependencies and resync the lockfile.

BREAKING CHANGE: @analogjs/vite-plugin-nitro no longer exports a Vite
plugin. Direct importers must migrate to @analogjs/platform, whose
analog() factory composes nitro() from 'nitro/vite' with Analog's
internal analogNitroPlugin. The ./internal sub-export (debugInstances)
is also removed. Type exports (SitemapConfig, PrerenderRouteConfig,
PrerenderContentDir, etc.) are now re-exported by @analogjs/platform.

Closes #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alogNitroPlugin.setup

The mapValues() pre-processing in platform-plugin.ts that stamped
'x-analog-no-ssr: true' onto routeRules with 'ssr: false' moves into
analogNitroPlugin's nitro.setup(nitro) hook, where it mutates
nitro.options.routeRules in place. This lets the injection survive
whether the Nitro config came in via analog({ nitro: ... }) or directly
through a user-invoked nitro() call.

Drops the unused mapValues import; restores the analogNitroPlugin
call in the platform plugin chain so the .nitro module actually runs.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entry

Promotes the previously-internal discoverLibraryRoutes helper to a
named export so workspace apps can compute additional page / content /
API directories once and feed them to both analog() and angular()
when the plugins are invoked separately.

Adds a small pageGlobs(dirs) helper that turns directories into
'${dir}/**/*.page.ts' globs, ready for angular({ include }).

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
analog() no longer internally invokes @analogjs/vite-plugin-angular.
Apps now call angular() themselves alongside analog():

    plugins: [analog(), angular(), nitro()]

Removed from analog()'s Options (hard-cut, not deprecated):
- vite (the passthrough to vite-plugin-angular)
- jit, disableTypeChecking, liveReload, inlineStylesExtension
- fileReplacements, fastCompile, fastCompileMode, include
- tailwindCss
- experimental.useAngularCompilationAPI
- experimental.stylePipeline.angularPlugins

Each plugin owns its own options. Pass debug to angular() to enable
analog:angular:* scopes — analog({ debug }) now controls
analog:platform:* and analog:nitro:* only.

depsPlugin no longer reads the vite/useAngularCompilationAPI flags:
it unconditionally excludes .ts/.js from Vite's built-in oxc transform
so vite-plugin-angular (when present) owns Angular file compilation.
Apps using an alternative compiler or compilation-API mode can
override the exclude in their own Vite config.

BREAKING CHANGE: The pass-through Angular options on analog() are
removed. Move them to angular() directly. The vite: false escape
hatch is no longer needed — drop angular() from your plugins array
to opt out of vite-plugin-angular.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
analog() no longer internally invokes nitro() from 'nitro/vite'. Apps
now call nitro() themselves alongside analog():

    plugins: [analog(), angular(), nitro()]

The analogNitroPlugin's .nitro property is still discovered by
nitro/vite via flattenPlugins(userConfig.plugins), so Analog's Nitro
module (SSR wrapper, page handlers, prerender, sitemap) plugs into
whatever Nitro config the user gave nitro() directly.

Drops the 'nitro' option from analog()'s Options (hard-cut) and the
NitroConfig import. The previous routeRules x-analog-no-ssr header
injection already lives inside analogNitroPlugin.setup(nitro), so it
keeps working against the user-supplied nitro() config.

Also removes the now-unused externalPlugins helper.

BREAKING CHANGE: analog({ nitro: ... }) no longer accepts a NitroConfig.
Pass Nitro config directly to nitro() from 'nitro/vite' in the Vite
plugin array.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…log-app

Reshapes apps/analog-app/vite.config.ts to match the public API
introduced by the prior commits:

    plugins: [analog(), angular(), nitro(), ...]

- analog() keeps file-routing/content/prerender/i18n options. Reads
  discovered workspace libs via discoverLibraryRoutes() (now exported
  from @analogjs/platform).
- angular() receives Angular-specific knobs directly: include
  (workspace lib *.page.ts globs via pageGlobs()), fileReplacements,
  inlineStylesExtension, fastCompile, workspaceRoot.
- nitro() receives routeRules. analogNitroPlugin (returned by
  analog()) is discovered via plugin.nitro and stamps
  'x-analog-no-ssr' on rules with 'ssr: false'.

Adds nitro: 'catalog:' to analog-app's package.json since the app now
imports { nitro } from 'nitro/vite' directly.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Options.input

nitro/vite's setupNitroContext (node_modules/nitro/dist/vite.mjs:710-734)
only reads ssr service entries from two sources:

1. pluginConfig.experimental.vite.services on the nitro() call
2. userConfig.environments.ssr.build.rollupOptions.input as a fallback

When analog() and nitro() are invoked separately, source #1 is empty
(the user's nitro() pluginConfig doesn't know about Analog's wrapper),
so source #2 is the only way our generated SSR service entry gets
registered. Without it, nitro/vite never wires the ssr service, the
ssr environment defaults to building index.html as an SSR input, and
Vite throws 'rollupOptions.input should not be an html file when
building for SSR'.

analogNitroPlugin now sets the input alongside the existing
experimental.vite.services.ssr.entry so it works whether nitro/vite
reads services from its own pluginConfig or falls through to the
environments fallback.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orchestration

@nx/vite:build iterates builder.environments and calls
builder.build(env) per environment, but it does not call
builder.buildApp(). nitro/vite's prerender + final nitro env build
orchestration lives inside the buildApp hook
(nitro/dist/vite.mjs:600-606 → buildEnvironments → prerender() + the
explicit builder.build(builder.environments.nitro) tail). Iterating
envs individually skips both, so only client + the ssr/nitro env
builds produce output — no prerender, no sitemap.

Switch analog-app's build target to nx:run-commands invoking
'vite build -c apps/analog-app/vite.config.ts'. The Vite CLI's
build path goes through buildApp, which triggers the full pipeline
(client → prerender → nitro env → close → writeBuildInfo).

Also drops the stale top-level build.outDir from vite.config.ts:
under nitro/vite the client output is owned by the client
environment's outDir which Nitro relocates to .output/public, so the
legacy '../../dist/apps/analog-app/client' override no longer
matches the active output path. Updates project.json outputs to
{workspaceRoot}/apps/analog-app/.output to match.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-entry.mjs

Vite 8's server.fs is strict by default, allowing fs fallback reads
only inside config.root. nitro/vite's env-runner worker imports its
own runtime entry from
node_modules/.pnpm/nitro@<hash>/.../dev-entry.mjs, which resolves
through pnpm's content-addressable store at the workspace root — one
level above the app's config.root. Vite's loadAndTransform sees a
non-allowed absolute path, skips fs.readFile, and throws 'Failed to
load url ... Does the file exist?' even though the file is right
there on disk.

Setting server.fs.allow to the workspace root in analog-app's vite
config lets Vite's load fallback succeed for any pnpm path the
env-runner reaches for.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
srvx's toNodeHandler checks res instanceof Promise to decide whether
to await the fetch handler's result. nitro/h3's dev fetch chain
returns a Promise<Promise<Response>> in some paths, so the outer
instanceof check awaits once and leaks the inner Promise through as
the response. srvx's sendNodeResponse then throws a TypeError when
it tries to spread Promise.prototype.headers (undefined, hence the
webRes.headers is not iterable error).

Patches the two call sites in srvx's adapters/node.mjs to recursively
await when the resolved value is itself thenable. Backport of the
fix from benpsnyder's PR on the same upstream issue.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-template

Nitro's resolveRendererOptions (nitro/dist/_chunks/nitro.mjs:296-311)
auto-detects index.html and installs renderer-template[.dev] as
renderer.handler. nitro/vite's configResolved branch that would
swap in its SSR-dispatch renderer only runs when both renderer.handler
and renderer.template are empty (nitro/dist/vite.mjs:574), which never
holds for a typical app with an index.html at root. Result: every
HTML request returns the raw template instead of routing through the
SSR service.

analogNitroPlugin now registers an explicit #analog/ssr-renderer
virtual in nitro.options.virtual and sets renderer.handler to it.
The handler short-circuits to the raw template when
x-analog-no-ssr is on the response (set by injectAnalogRouteRuleHeaders
from routeRules with ssr: false) and otherwise dispatches through
fetchViteEnv to the SSR service env.

The SSR-wrapper service entry keeps its own response shape; the
no-ssr request-header check stays in place as a defense-in-depth path.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… of fetchViteEnv

nitro/vite's prodSetup polyfill (which populates globalThis.__nitro_vite_envs__
so fetchViteEnv can dispatch to the SSR service) is registered as a Vite
plugin virtual and added to nitro.options.unenv.polyfill. That works for
the Vite-built main bundle, but Nitro's prerender forces
builder: 'rolldown' on its own Nitro instance, and the Rolldown build
doesn't run Vite plugins. The nitro-vite-setup polyfill stays unresolved,
the env-services global is never set, fetchViteEnv throws HTTPError 404,
and every SSR route fails the prerender silently (Nitro reports 0 routes
prerendered).

Switch the renderer to a Nitro virtual indirection. analogNitroPlugin
registers 'analog-ssr' as a function-valued nitro.options.virtual that
resolves bundler-agnostically:

- Dev: emits a thin fetch adapter that delegates to fetchViteEnv,
  since the SSR service module isn't on disk yet and nitro/vite's env
  runner is the dispatch path.
- Build / prerender: scans the SSR services build dir for the entry
  built by nitro/vite's services pipeline and re-exports it directly.
  By the time Nitro resolves the virtual for the main bundle (built
  last) or for the prerender bundle (built after the main build
  completes), the SSR service file exists.

The renderer virtual now imports from analog-ssr and calls its fetch
method. No Vite-specific runtime globals; works in any bundler Nitro
picks. Prerendered 6/6 routes in the analog-app verification.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…non-SSR and edge cases

Three small additions to analogNitroPlugin found while migrating the
other demo apps:

1. Gate the analog-owned SSR renderer registration behind options.ssr.
   When ssr is false, leave Nitro's auto-detected template-serving
   renderer in place — serving the raw index.html for every HTML
   request is exactly the desired behavior, and our renderer virtual
   would otherwise try to dispatch to an SSR service that was never
   built. Lets tailwind-debug-app (ssr: false) build cleanly.

2. Carry over Analog's Nitro externals list. rxjs's facade subpaths
   confuse Nitro/Rolldown's resolver, node-fetch-native's polyfill
   rewrites global fetch, and sharp ships platform-specific binaries
   under @img/sharp-* whose unused symlinks crash Nitro's externals
   plugin with ENOENT during realpath(). All three were externalized
   by the legacy @analogjs/vite-plugin-nitro orchestrator; they need
   to be restored under the new plugin chain or blog-app's nitro env
   build fails on the sharp symlink walk.

3. Carry over the Nitro/Rolldown bundler-config sanitizer. Nitro's
   Rollup config sets output.codeSplitting (rejected as unknown by
   Rolldown), output.manualChunks (crashes Nitro's prerender
   rebundle), and a chunkFileNames function that emits route-derived
   [token] patterns Rollup/Rolldown treats as placeholders. The
   sanitizer strips the first two and rewrites non-standard [token]
   patterns to _token_.

All three apply in the rollup:before hook on the resolved Nitro
bundler config so they survive into both the main bundle and the
prerender bundle.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…plugin shape

Migrates opt-catchall-app, tanstack-query-app, blog-app, and
tailwind-debug-app to the public-API shape introduced earlier on this
branch:

    plugins: [analog(), angular(), nitro(), ...]

Per app:

- vite.config.ts:
  - Add explicit imports for @analogjs/vite-plugin-angular and
    nitro from 'nitro/vite'.
  - Drop the top-level build.outDir override that Nitro relocates
    anyway under nitro/vite (.output/public).
  - Add server.fs.allow pointing at the workspace root so Vite's fs
    fallback can read pnpm content-hash paths reached by nitro/vite's
    env-runner (dev-entry.mjs).
  - Pull angular-specific options out of analog(): liveReload,
    inlineStylesExtension, useAngularCompilationAPI, fileReplacements,
    tailwindCss, fastCompile.
  - Pull nitro-specific options out of analog(): routeRules,
    nitro.prerender.*, nitro.experimental.websocket.
  - analog() keeps the file-routing/content/prerender/i18n surface.
- project.json: switch the build target from @nx/vite:build to
  nx:run-commands invoking 'vite build -c ...', so Vite's CLI
  buildApp pipeline runs (nitro/vite's prerender + final nitro env
  build orchestration lives in the buildApp hook). Update outputs to
  apps/<app>/.output.
- package.json: add 'nitro: catalog:' to devDependencies for the
  direct 'nitro/vite' import; add @analogjs/vite-plugin-angular where
  it wasn't yet declared.

Builds verified:
- opt-catchall-app: server bundle only (no prerender configured)
- tanstack-query-app: server bundle only
- blog-app: 8 prerendered routes + sitemap, ssr-rendered HTML
- tailwind-debug-app: ssr: false, server bundle only (Nitro
  auto-template-handler serves raw HTML)

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the v3 breaking change introduced earlier on this branch:

- analog() no longer internally invokes @analogjs/vite-plugin-angular
  or nitro/vite; users call each plugin explicitly and pass each
  option to the plugin that now owns it.
- @analogjs/vite-plugin-nitro is deprecated; the orchestration moved
  into @analogjs/platform.
- Lists every option that moved off analog() and where it lives now.
- Documents the discoverLibraryRoutes + pageGlobs helpers exported
  from @analogjs/platform for workspace-library include patterns.
- Explains the Nx build target switch (@nx/vite:build cannot trigger
  nitro/vite's buildApp hook; nx:run-commands invoking vite build
  is required for prerender + final Nitro env build).
- Notes the server.fs.allow workaround for Vite 8 strict fs in pnpm
  monorepos.

Adds the corresponding bullets under the automated-migration notes.

Refs #2035

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v3 separated-plugin shape imports nitro from 'nitro/vite' directly
in vite.config.ts, so scaffolded apps need nitro declared as a direct
dependency. Pin to 3.0.260415-beta to match the workspace catalog
version currently in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngular + nitro)

Updates the latest, minimal, and blog templates so scaffolded apps
match the v3 separated-plugin shape:

- import angular from '@analogjs/vite-plugin-angular'
- import { nitro } from 'nitro/vite'
- plugins: [analog(), angular(), nitro()] (latest, blog)
- plugins: [analog({ ssr: false, ... }), angular(), nitro({ static: true })] (minimal)

Tailwind placeholder tokens (__TAILWIND_IMPORT__ / __TAILWIND_PLUGIN__)
and the blog content highlighter token (__CONTENT_HIGHLIGHTER__) are
preserved so create-analog's scaffolding substitution keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ration

Adds a lightweight schematic at version 3.0.0-alpha.56 that runs
during `ng update @analogjs/platform@latest`:

- Visits every vite.config.{ts,mts,js,mjs} in the workspace and
  detects the legacy single-call analog() shape (default import from
  @analogjs/platform + an analog() call + no companion import from
  @analogjs/vite-plugin-angular and nitro/vite).
- If any are found:
  - Adds @analogjs/vite-plugin-angular to devDependencies, matching
    the existing @analogjs/platform pin so the angular plugin stays
    on the same release line.
  - Adds 'nitro: 3.0.260415-beta' to devDependencies — the version
    the workspace catalog currently pins for nitro/vite.
  - Schedules a package install task.
  - Logs each detected file and a link to the v2-to-v3 migration
    guide for the option-relocation step (which is too workspace-
    specific to safely automate).

Doesn't rewrite vite.config sources. The option moves between
analog(), angular(), and nitro() are case-by-case enough that
forcing an automatic rewrite would either be very large (full AST
transform) or break common variations; pointing users at the docs is
the honest middle ground.

Built on @angular-devkit/schematics (not @nx/devkit) to match the
existing platform migration setup. Tests cover detection, dep
add/skip paths, dependencies-vs-devDependencies version lookup, the
node_modules skip, .mts discovery, and the path-prefix check that
keeps non-vite-config files from triggering the migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vite + analog.nitro

The schematic now attempts a real source rewrite for the two
unambiguous option moves:

- `analog({ vite: VALUE })` -> `angular(VALUE)`
- `analog({ nitro: VALUE })` -> `nitro(VALUE)`

Implementation parses the vite.config file with the TypeScript compiler
to locate the single `analog(...)` call. If its argument is an object
literal containing `vite` and/or `nitro` properties, those property
values are extracted verbatim from the source (preserving original
formatting, comments, trailing commas), and the call is replaced with
three calls in sequence: a slim `analog({...remaining})`, then
`angular(VALUE)` and `nitro(VALUE)`. The companion imports for
`@analogjs/vite-plugin-angular` and `nitro/vite` are inserted right
after the `@analogjs/platform` import.

The transform is intentionally narrow:

- only fires when the file has exactly one `analog(...)` call,
- only fires when its argument is an object literal,
- only moves the two named keys.

When the file doesn't fit (no argument, variable argument, multiple
`analog()` calls, parse failure), the schematic falls back to the
previous behavior: log the file path and the migration-guide URL so
the user can move the options by hand. Either way, deps get added and
a package install is scheduled.

Six new tests cover the rewrite paths:

- `vite` lifted into a companion `angular(...)`
- `nitro` lifted into a companion `nitro(...)`
- both lifted while other analog options stay on `analog(...)`
- `analog()` with no argument: no rewrite, instructions logged
- `analog(opts)` where `opts` is a variable: no rewrite, instructions
  logged
- existing `angular` import isn't duplicated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… + nitro()

The schematic now rewrites every shape it can statically reason about:

- analog() (no argument)                  -> analog(), angular(), nitro()
- analog({ apiPrefix: 'api' })            -> analog({ apiPrefix: 'api' }), angular(), nitro()
- analog({ vite: V })                     -> analog(), angular(V), nitro()
- analog({ nitro: N })                    -> analog(), angular(), nitro(N)
- analog({ vite: V, nitro: N, ...rest })  -> analog({ ...rest }), angular(V), nitro(N)

Only analog(variable) and analog(someCall()) still bail out to the
logging-only fallback, since we can't statically split a value we
don't have an object literal for.

Two tests updated/added to cover the argumentless and
no-vite-no-nitro paths; full migration spec now at 15 tests, all
passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ess.cwd()

The plugin previously defaulted workspaceRoot to process.cwd(), which
is correct when the user runs vite build from the workspace root.
But when the build runs from an app's own directory (or any other
non-workspace cwd) inside a monorepo, workspace-relative paths (e.g.
fileReplacements entries written as apps/<app>/src/...) fail to
resolve.

Check NX_WORKSPACE_ROOT before process.cwd() so the standard Nx
invocation already has the right default even when cwd happens to be
the app dir. The legacy single-call analog() did the same thing
implicitly; this restores that behavior for users who now invoke
angular() directly.

User-supplied workspaceRoot still takes precedence, so anyone needing
a specific value can override it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@analogjs/vite-plugin-angular now defaults workspaceRoot to
NX_WORKSPACE_ROOT (and then process.cwd()), which is the right value
for every flow these apps actually use:

- pnpm nx build <app>: Nx sets NX_WORKSPACE_ROOT and runs from the
  workspace root, so the default resolves correctly with or without
  cwd being the workspace.
- standalone scaffolded apps (templates): process.cwd() is the
  project root which IS the workspace root for those users.

The explicit workspaceRoot: resolve(__dirname, '../..') override the
migration originally added was defensive; remove it from each
migrated app config now that the plugin's own default covers the
same ground.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The patch was added to work around srvx's toNodeHandler awaiting only
one level of promise, while nitro/h3's fetch chain at the time was
returning Promise<Promise<Response>> through the SSR handler path.

Subsequent SSR-renderer refactors on this branch removed the second
promise layer:
- 7b938f7 fix(platform): install analog-owned SSR renderer to bypass
  Nitro auto-template
- 16e73ab fix(platform): use Nitro virtual indirection for SSR
  dispatch instead of fetchViteEnv
- d9d842a fix(platform): gate SSR renderer + add Nitro externals

The renderer virtual now returns service.fetch(event.req) directly,
which is a single-level Promise<Response>; combined with h3 v2's
recursive toResponse() unwrap, srvx's vanilla single-level await is
sufficient.

Verified by rebuilding analog-app against vanilla srvx@0.11.15 and
hitting the production server on SSR routes, ssr: false routes, API
JSON, and SSR catch-all 404s; all returned 200 with the expected
hydration tokens.
Publish ./builders/vite and ./builders/vite-dev-server in the package
exports map so packages that ship Angular Devkit Architect builders
(@analogjs/platform) can land on a real, resolvable file via a thin
re-export shim instead of a cross-package string redirect in
builders.json.

This mirrors @storybook/angular's pattern of exporting
./builders/build-storybook and ./builders/start-storybook.
…vite

Replace the cross-package string redirect

  vite: '@analogjs/vite-plugin-angular:vite'

in @analogjs/platform's builders.json with thin local Architect builders
that import + re-export the real implementations from
@analogjs/vite-plugin-angular/builders/vite (and /builders/vite-dev-server).

The string-redirect form had two problems when invoked through Nx:
1. Architect resolves the redirect via package.json/builders.json walks
   starting from the root node_modules. The root-level pnpm copy of
   @analogjs/platform shadows the per-app workspace symlink, so changes
   to the source package didn't take effect until a fresh pnpm install.
2. The Nx 'vite' executor entry pointed at @nx/vite's viteBuildExecutor
   which doesn't call createBuilder().buildApp() and therefore never
   triggers Nitro's prerender + server pipeline.

The shim pattern (mirroring @analogjs/storybook-angular's
re-export of @storybook/angular/builders/build-storybook) keeps the
user-facing @analogjs/platform:vite identifier stable while landing
Architect on a real local file. The actual builder implementation
continues to live in @analogjs/vite-plugin-angular.

Also drops the dead Nx vite executor source and its build entry, and
removes the 'vite' entry from the executors block of executors.json so
Nx falls through to the builders block (architect compat) for the
shim.
Switches the five demo apps (analog-app, blog-app, opt-catchall-app,
tailwind-debug-app, tanstack-query-app) from the nx:run-commands shim
that invoked 'vite build -c <config>' directly to the supported
@analogjs/platform:vite executor.

The platform builder forwards through a local Architect shim to the
implementation in @analogjs/vite-plugin-angular, which calls
createBuilder(cfg).buildApp() and drives the full Vite environment
pipeline (client + ssr + nitro) plus Nitro prerender and sitemap.

This is the executor identifier that platform's app generators emit
for newly scaffolded apps, so the demo apps now match the shape an
end user gets from `nx g @analogjs/platform:app`.
getPageHandlers hard-coded '/api' as the prefix for discovered
server-side page routes, and the analog-nitro-plugin's hasAPIDir check
hard-coded 'src/server/routes/api'. A user setting
`analog({ apiPrefix: 'rpc' })` got page endpoints mounted under
'/api/_analog/pages/...' regardless and the hasAPIDir probe missed
the existing 'src/server/routes/rpc/' directory.

Pass apiPrefix through to getPageHandlers and use it as both the
filesystem probe segment and the route prefix. Strips any user-supplied
leading slashes before composing the path so both 'api' and '/api'
forms produce the same output.
The /\/\((.*?)\)$/ replace only matched a single group at the end of
the route path. Pages organized into Angular Router groups beyond the
last segment (e.g. `(auth)/login.server.ts` or
`(group)/users/[id].server.ts`) kept the literal parens in their
mounted route — Nitro then registers an invalid path the runtime
can't reach.

Switch to a global regex matching any `/(group)` segment and rewrite
it to /-group- per the docstring's example.
event.path?.startsWith(apiPrefix) matched any path that had apiPrefix
as a literal prefix string, so a request to /apiary with apiPrefix
of /api would be incorrectly routed through the API middleware (and
have the leading /api stripped to produce a nonsensical 'ry' reqUrl).

Require either an exact match or a / boundary after the prefix
before treating the request as an API call.
The handler registered on `prerender:generate` was synchronous and
swallowed the returned promise from each hook. Async post-rendering
hooks (i18n route expansion, content-derived output writes, etc.)
could race past the next prerender step, and any thrown error was
silently lost because Nitro never saw a rejected promise from the
hook callback.

Mark the wrapper async and await each hook so errors propagate and
`prerender:generate` blocks on completion. Widens the hook signature
to `Promise<void> | void` so sync callers stay supported.
The previous /\/([^/.]+)(\.([^/.]+))?$/ split content file paths on
the FIRST dot, so a file named `post.en.md` was parsed as name='post'
and extension='en' (losing both the locale suffix and the actual
extension). Authors relying on locale-tagged filenames had their
suffixes silently dropped from the PrerenderContentFile metadata.

Switch to a basename + lastIndexOf('.') split so the trailing segment
becomes the extension and everything before it (inner dots included)
stays in name. `post.en.md` now yields name='post.en' and
extension='md'.
The route-skip guard checked `route.startsWith('/api/')`, which
matches every nested API path but lets the bare `/api` route fall
through into locale expansion. expandRoutesWithLocales would then emit
phantom `/en/api`, `/fr/api`, etc., that the user never intended
to prerender.

Treat `route === '/api'` as a sibling exact match alongside the
existing prefix check.
XMLBuilder is only used as a type annotation in build-sitemap.ts.
Importing it as a runtime symbol from xmlbuilder2/lib/interfaces
risks pulling in (or breaking on) the internal subpath at runtime
in environments where bundlers don't tree-shake unused values.
Switch to `import type` so the symbol is erased after compilation.
The dev-server builder's `port` option accepted any JSON `number`,
so fractional values like 43000.5 passed schema validation and only
failed later inside Node's net stack. Switch to `integer` and bound
the range to the valid TCP port window (1-65535) so misconfigurations
surface at validation time.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/platform/src/lib/nitro/analog-nitro-plugin.ts (1)

649-677: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore staticData endpoint prerendering for PrerenderContentDir routes.

collectRoutes() now restores outputSourceFile, but it still skips the staticData expansion in the contentDir branch. That drops /<apiPrefix>/_analog/pages/... prerenders for transformed content routes and breaks parity with prior behavior.

Proposed fix
       for (const file of files) {
         const route = dir.transform(file);
         if (route === false) continue;
         out.push(route);
@@
         if (dir.outputSourceFile) {
           const sourceContent = dir.outputSourceFile(file);
           if (typeof sourceContent === 'string') {
             routeSourceFiles[route] = sourceContent;
           }
         }
+        if (dir.staticData) {
+          const prefix = apiPrefix.startsWith('/') ? apiPrefix : `/${apiPrefix}`;
+          const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
+          out.push(`${prefix}/_analog/pages${normalizedRoute}`);
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/platform/src/lib/nitro/analog-nitro-plugin.ts` around lines 649 -
677, In collectRoutes (analog-nitro-plugin.ts) the PrerenderContentDir branch
processes transform(), sitemaps and outputSourceFile but does not add the
corresponding staticData prerender routes (the /<apiPrefix>/_analog/pages/...
endpoints), causing missing prerenders; update the 'contentDir' branch
(PrerenderContentDir handling) to mirror the staticData expansion logic used
elsewhere: after computing route and routeSourceFiles[route], also push the
generated staticData route(s) into out (using the same route -> static data
endpoint naming as the rest of collectRoutes) and include any associated
routeSourceFiles/sitemaps entries the same way so transformed content routes get
their /_analog/pages/* prerenders restored.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/platform/src/lib/nitro/i18n-prerender.ts`:
- Around line 24-28: The route-exclusion predicate in i18n-prerender.ts
currently hardcodes '/api'; update it to honor the configured apiPrefix by
changing the predicate to check route === apiPrefix ||
route.startsWith(apiPrefix + '/') and by accepting an apiPrefix parameter into
the function (or into wirePrerender's internal call) so the correct prefix is
used; update the caller (wirePrerender) to pass the configured apiPrefix through
to this check so custom API prefixes (e.g., '/rpc') are excluded from locale
expansion.

---

Duplicate comments:
In `@packages/platform/src/lib/nitro/analog-nitro-plugin.ts`:
- Around line 649-677: In collectRoutes (analog-nitro-plugin.ts) the
PrerenderContentDir branch processes transform(), sitemaps and outputSourceFile
but does not add the corresponding staticData prerender routes (the
/<apiPrefix>/_analog/pages/... endpoints), causing missing prerenders; update
the 'contentDir' branch (PrerenderContentDir handling) to mirror the staticData
expansion logic used elsewhere: after computing route and
routeSourceFiles[route], also push the generated staticData route(s) into out
(using the same route -> static data endpoint naming as the rest of
collectRoutes) and include any associated routeSourceFiles/sitemaps entries the
same way so transformed content routes get their /_analog/pages/* prerenders
restored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6c0ef65c-acd9-421c-a5dc-6d94f4901b81

📥 Commits

Reviewing files that changed from the base of the PR and between 94667f4 and 6db73b9.

📒 Files selected for processing (8)
  • packages/nx-plugin/src/builders/vite-dev-server/schema.json
  • packages/platform/src/lib/nitro/analog-nitro-plugin.ts
  • packages/platform/src/lib/nitro/build-sitemap.ts
  • packages/platform/src/lib/nitro/get-content-files.ts
  • packages/platform/src/lib/nitro/get-page-handlers.ts
  • packages/platform/src/lib/nitro/i18n-prerender.ts
  • packages/platform/src/lib/nitro/post-rendering-hook.ts
  • packages/platform/src/lib/nitro/renderers.ts

Comment on lines +24 to +28
if (
route.includes('/_analog/') ||
route === '/api' ||
route.startsWith('/api/')
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make API-route exclusion honor configured apiPrefix, not hardcoded /api.

This predicate still hardcodes /api. For custom prefixes, API routes may be incorrectly locale-expanded (e.g., /rpc/... becoming /en/rpc/...).

Proposed direction
-export function expandRoutesWithLocales(
+export function expandRoutesWithLocales(
   routes: string[],
   i18n: I18nPrerenderOptions,
+  apiPrefix = 'api',
 ): string[] {
+  const normalizedApiPrefix = apiPrefix.startsWith('/')
+    ? apiPrefix
+    : `/${apiPrefix}`;
@@
     if (
       route.includes('/_analog/') ||
-      route === '/api' ||
-      route.startsWith('/api/')
+      route === normalizedApiPrefix ||
+      route.startsWith(`${normalizedApiPrefix}/`)
     ) {

…and pass apiPrefix from the wirePrerender() caller.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/platform/src/lib/nitro/i18n-prerender.ts` around lines 24 - 28, The
route-exclusion predicate in i18n-prerender.ts currently hardcodes '/api';
update it to honor the configured apiPrefix by changing the predicate to check
route === apiPrefix || route.startsWith(apiPrefix + '/') and by accepting an
apiPrefix parameter into the function (or into wirePrerender's internal call) so
the correct prefix is used; update the caller (wirePrerender) to pass the
configured apiPrefix through to this check so custom API prefixes (e.g., '/rpc')
are excluded from locale expansion.

The migration to nitro/vite dropped two pieces of behavior that
@analogjs/vite-plugin-nitro shipped:

1. Vercel CI auto-detection. The legacy plugin promoted
   process.env.VERCEL (set on every Vercel build) and the
   BUILD_PRESET env var into the Nitro preset. Nitro v3 only reads
   NITRO_PRESET / SERVER_PRESET, so users upgrading kept hitting the
   default node-server preset on Vercel deployments and Vercel CLI
   refused to deploy what looked like a non-Vercel build.

2. Vercel runtime defaults. entryFormat: 'node' and functions.runtime:
   'nodejs24.x' were applied so the emitted Build Output API config
   targets the supported Node runtime tier.

Bridge BUILD_PRESET -> NITRO_PRESET and VERCEL -> 'vercel' in the
config() hook (early enough that Nitro's createNitro() picks the
preset up), and apply the runtime defaults in setup() when Vercel is
active. Skip the legacy dist/<rootDir>/analog/ output override for
Vercel — Nitro's preset owns the Build Output API layout and
overriding only output.dir left the functions/ subtree at Nitro's
project-root default while static files moved, splitting the deploy
across two trees and breaking Vercel's discovery.

Default (non-Vercel) builds still use the legacy dist/<rootDir>/analog/
paths.
Commit 1a59b6a moved the vite executor from the dead Nx-executor
forward at src/executors/vite/vite.impl.ts to the architect-builder
shims under src/builders/vite/ and src/builders/vite-dev-server/, but
the post-build artifact-verification list at
tools/scripts/verify-package-artifacts.mts still required the legacy
executors/vite/vite.impl.js path. CI's prepare step calls
verify-package-artifacts.mts platform and fails with a missing-
artifact error pointing at the deleted path.

Replace the stale entry with the two shim outputs that actually ship
in the dist tree.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/platform/src/lib/nitro/analog-nitro-plugin.ts`:
- Around line 238-240: The isVercel detection currently treats any VERCEL env
var as true even when an explicit Nitro preset is set; change the logic so an
explicit nitro.options.preset always wins: compute the preset
(nitro.options.preset) first and only consider process.env['VERCEL'] when the
preset is empty/undefined, i.e. make isVercel true if the preset contains
'vercel' OR if there is no preset and VERCEL is present (use the existing
identifiers nitro.options.preset, isVercel, and process.env['VERCEL']).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3d80f0f2-1342-4278-bea6-dc78b8466827

📥 Commits

Reviewing files that changed from the base of the PR and between 6db73b9 and 2e72570.

📒 Files selected for processing (1)
  • packages/platform/src/lib/nitro/analog-nitro-plugin.ts

Comment thread packages/platform/src/lib/nitro/analog-nitro-plugin.ts Outdated
The prepare script chained:
  git config core.hookspath .githooks || true && node tools/scripts/build-release.mts

cmd.exe doesn't have a true builtin. On Windows the chain evaluates
as (git || true) && node, where the unknown true command returns
9009, the OR short-circuits to that exit, and the AND that follows
never runs node. Result: the script prints Done in ~40 ms with no
work performed, the platform dist never gets built during pnpm
install, and subsequent pnpm build invocations fail with
'Cannot find module .../platform/dist/src/lib/nx-plugin/executors.json'
when Nx tries to resolve @analogjs/platform:vite from project.json.

Move the git-config call into build-release.mts inside a try/catch so
it runs cross-platform and a missing-git environment is a soft skip.
Simplify prepare to a single node invocation.
…atic

oxlint's prefer-const rule flags the analogCalls binding in
migrate-to-separated-plugins.ts: the array is mutated via push() but
the binding itself is never reassigned. CI's Linux / Lint job fails
with one extra error against this PR vs origin/alpha because of it.

Switch to const so the lint count matches the baseline upstream.
…inal

The filter that strips analog plugins from the user's vite.config
called .includes() on every plugin's name unconditionally:

  config.plugins.flat().filter(p => !p.name.includes('analogjs'))

A plugin with an undefined name (or any falsy entry slipping through
the user's plugins array) throws:

  TypeError: Cannot read properties of undefined (reading 'includes')

which fails build-storybook with 'Broken build, fix the error above'.

Use optional chaining on both the plugin entry and its name so
unnamed/falsy plugins fall through untouched and only entries with a
name explicitly containing 'analogjs' are dropped.
@github-actions github-actions Bot added the scope:storybook-angular Changes in @analogjs/storybook-angular label May 25, 2026
brandonroberts and others added 8 commits May 25, 2026 16:42
Analog v3 no longer installs the legacy /api/** -> /** proxy
routeRule (the @analogjs/vite-plugin-nitro fallback for projects
without a routes/api/ directory). blog-app's e2e expects
/api/rss.xml to serve the RSS feed and /api/v1/* for OG image
helpers, but its routes lived directly at routes/rss.xml.ts and
routes/v1/, which Nitro mounted at /rss.xml and /v1/* under v3.

Move both into routes/api/ so they land at the URL paths the app's
own client + e2e tests reference.
Angular's HttpClient (via withFetch) and Analog's injectLoad() issue
HTTP calls during SSR/prerender to addresses like
http://localhost/api/_analog/pages/...  When the SSR service wrapper
didn't pass a fetch implementation to the renderer, those calls fell
through to the default global fetch — which ECONNREFUSEd during
prerender because no socket is listening, and on a live server hit
the loopback instead of the in-process Nitro pipeline.

The legacy @analogjs/vite-plugin-nitro renderer constructed a
request-scoped fetch with h3's fetchWithEvent and passed it as
`fetch` into Angular's renderApplication. The new two-env split
(renderer virtual in Nitro env, SSR service in Vite env) lost that
wiring because fetchWithEvent needs the live h3 event which can't
cross env-runner boundaries.

Use Nitro v3's nitro/app serverFetch instead — it does an in-process
fetch through useNitroApp().fetch without needing the originating
event. Build the fetch in the SSR service wrapper and pass it as
`fetch` into the renderer call. Relative URLs get the http://localhost
host prefix Nitro's Request constructor needs.

Restores route-level SSR output: prerendered pages now carry the
full Angular-rendered router-outlet content instead of just the
shell with the top bar.
… fetch in ofetch

Two related bugs in the nitro/vite migration's preset handling and SSR
fetch wiring broke Netlify deployments:

1. The setup() output-path override unconditionally redirected every
   non-Vercel preset to dist/<rootDir>/analog/, clobbering Netlify's
   {{rootDir}}/.netlify/functions-internal/ path. Netlify deploys 404'd
   on both functions and assets because the artifacts never landed where
   the platform expected them.

2. The SSR entry wrapper passed a plain fetch as the renderer's fetch
   option, which becomes INTERNAL_FETCH. The router's request-context
   interceptor short-circuits SSR HttpClient calls through
   serverFetch.raw(...) — an ofetch API. Plain fetch lacks .raw, so
   every SSR data load threw TypeError during prerender.

Changes:

- Bridge NETLIFY and CF_PAGES env vars into NITRO_PRESET alongside the
  existing VERCEL bridge.
- Make the output-path override preset-aware: only apply the legacy
  dist/<rootDir>/analog/ paths for the default node-server preset.
  Managed presets (Vercel, Netlify, Cloudflare, ...) use their own
  layouts.
- For Netlify, hoist functions to <workspaceRoot>/.netlify/functions-internal/
  so the deploy auto-discovers them; keep publicDir at
  dist/<rootDir>/analog/public/ for netlify.toml publish wiring.
- Wrap nitroServerFetch in ofetch's createFetch in the SSR entry wrapper
  and pass that as the renderer fetch, so INTERNAL_FETCH has .raw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /app subpath creates a fresh `useNitroApp()` instance scoped to the
importing bundle. In the SSR vite bundle that has no registered route
handlers, so every page-endpoint fetch from injectLoad() 404'd during
prerender (`FetchError: [GET] "/api/_analog/pages/-home-": 404").

The root `nitro` entry's serverFetch reads
`globalThis.__nitro__.{default,prerender}` instead, which the
surrounding Nitro server (prerender pass or production runtime) has
already populated with the real app + handlers. Page-endpoints now
resolve correctly during prerender.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e root

Nitro's Vercel and Cloudflare presets anchor their output paths at
`{{rootDir}}`, which for Nx monorepos lands deployment artifacts under
`apps/<name>/` where the deploy CLIs (`vercel build`, `wrangler pages
deploy`) can't auto-discover them. Same shape as the Netlify issue
already fixed; extend the hoist:

- Vercel: `<workspaceRoot>/.vercel/output/{functions/__server.func,static}/`
  so `vercel build` at the repo root finds the Build Output API tree.
- Cloudflare Pages/Workers: `<workspaceRoot>/dist/<rootDir>/` with
  `_worker.js/` alongside static assets, so `wrangler pages deploy
  dist/<rootDir>` from the workspace root works.

Verified each preset emits to the expected workspace-root layout with
`BUILD_PRESET=<preset> nx build analog-app`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nitro v3's prerender writer compares filePath.startsWith(publicDir),
but filePath is built via node:path.join (platform-native separators
on Windows) while publicDir comes from resolveNitroPath which always
returns forward-slash paths via pathe. On Windows the two sides
disagree and every prerendered route is marked (skipped) — the CI
'more dist\apps\blog-app\analog\public\index.html' verify step
then fails because no HTML was written.

Hook prerender:route to detect the skip-after-generate case (skip=true
with non-empty data) and write the file ourselves under the override
publicDir, then clear the skip flag so the prerender count is honest.
Idempotent across the two-fire prerender:route hook contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ve SSR query string

Two distinct SSR regressions in this PR's nitro/vite migration:

1) /assets/<file> 404'd during SSR / fell through to the catch-all
   SSR renderer, leaking HTML where consumers (router request-context
   interceptor, page `HttpClient.get` calls) expected JSON or a real
   asset. nitro/vite turns off Vite's `build.copyPublicDir`
   (vite.mjs:248) and expects Nitro to manage public assets, but it
   doesn't auto-bridge the user's `publicDir` setting. Capture
   `userConfig.publicDir` in `config()` and register it as a Nitro
   `publicAssets` entry in `setup()`. Also bridge `output.publicDir`
   into the nested prerender Nitro via the `prerender:config` hook
   so the prerender's asset manifest scan finds the same files.

2) URL query string was being dropped before the Angular renderer,
   so `route.queryParams` was always empty and
   `definePageLoad({ query })` saw nothing. Renderer was called with
   bare `requestPath`; pass `requestUrl` (path+search) instead.

Verified: `Hello, ANALOG!` now renders for `/greet/analog?shout=true`
and `/assets/shipping.json` is no longer caught by the SSR fallback
during prerender. e2e count drops by 1 (16 → 15 failing); remaining
failures are client-side hydration/interactivity issues unrelated to
SSR routing or static assets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brandonroberts brandonroberts merged commit c22378a into alpha May 26, 2026
29 of 36 checks passed
@brandonroberts brandonroberts deleted the feat/nitro-vite-plugin branch May 26, 2026 01:49
brandonroberts added a commit that referenced this pull request May 26, 2026
…) + angular() + nitro() (#2343)

Closes #2035
Closes #2188

BREAKING CHANGE:

`@analogjs/vite-plugin-nitro` → `nitro/vite`

  `@analogjs/platform`'s `analog()` now bundles Nitro v3's first-party `nitro/vite` plugin instead of `@analogjs/vite-plugin-nitro`. Standalone users
  (those who used `@analogjs/vite-plugin-nitro` directly) must install `nitro` as a dev dep and add `nitro()` to their Vite plugin chain alongside `analog()`.

BEFORE:

```ts
  // vite.config.ts
  import analog from '@analogjs/platform';
  import { defineConfig } from 'vite';

  export default defineConfig({
    plugins: [analog({ /* ... */ })],
  });
```

AFTER:

```ts
  // vite.config.ts
  import analog from '@analogjs/platform';
  import angular from '@analogjs/vite-plugin-angular';
  import { nitro } from 'nitro/vite';
  import { defineConfig } from 'vite';

  export default defineConfig({
    plugins: [
      analog({ /* ... */ }),
      angular({ /* ... vite object */ }),
      nitro({ /* ... nitro object*/ })
    ],
  });
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope:create-analog Changes in create-analog scope:docs Documentation changes scope:nx-plugin Changes in @analogjs/nx-plugin scope:platform Changes in @analogjs/platform scope:repo Repository metadata and tooling scope:storybook-angular Changes in @analogjs/storybook-angular scope:vite-plugin-angular Changes in @analogjs/vite-plugin-angular

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant