Skip to content

SSR / Netlify gotchas

The Astro integration in packages/core/src/integration.ts applies a set of Vite configuration patches that are non-obvious but load-bearing. This page documents each one and why it exists.

noExternal for @drawnagency/* and @atlaskit/*

Section titled “noExternal for @drawnagency/* and @atlaskit/*”
ssr: {
noExternal: [/^@drawnagency\//, /^@atlaskit\//, ...primitivesDeps],
}

In an SSR build, Vite can leave dependencies “external” — loaded by Node at runtime rather than bundled. External deps must be resolvable from the project root’s node_modules/. With pnpm’s strict hoisting, @drawnagency/primitives’ transitive dependencies are hoisted only into packages/primitives/node_modules/, not the project root. The source-alias system (see below) makes Vite process primitives source files as project code, which means any dep that stays external needs to resolve from the project root — and fails.

The fix: force all @drawnagency/*, @atlaskit/*, and all direct dependencies of @drawnagency/primitives (except dexie and dompurify, which are browser-only) into the SSR bundle via noExternal.

resolve.alias redirecting primitives to src/

Section titled “resolve.alias redirecting primitives to src/”
resolve: {
alias: [...primitivesAliases],
preserveSymlinks: true,
},

The integration reads @drawnagency/primitives/package.json exports and creates a Vite alias for every entry path, redirecting imports from dist/ to the package’s src/ directory (via the .portal/primitives symlink). For example, @drawnagency/primitives (root) aliases to .portal/primitives/src/index.ts.

Without this alias, Rollup splits the registry module across chunks:

  • src/ files (accessed via @/ project aliases) get one copy.
  • dist/ files (accessed via package exports) get another.

Two copies means two independent registry singletons. Sections registered in one are invisible to the other, producing the runtime error:

At least 2 section schemas must be registered

Symbol.for("@drawnagency/primitives/registry") is the safety net: even if the module is duplicated despite the alias, all copies share one underlying registry instance via globalThis. But the alias prevents duplication in the first place — the Symbol.for guard is defense-in-depth.

Required alongside the alias. Without it, Vite resolves symlinks to their real paths (inside packages/primitives/src/) and may re-deduplicate modules back to dist/, defeating the alias. preserveSymlinks: true keeps symlinked paths opaque.

esbuild: {
jsx: "automatic",
jsxImportSource: "react",
}

@drawnagency/* packages ship React components. When these packages are pulled into the SSR bundle via noExternal, esbuild processes their JSX. Without jsx: "automatic", the classic JSX transform is used, which looks for React in scope — breaking in packages that use the automatic transform (i.e. all of them). Setting this in the integration ensures the correct transform is applied to all bundled package code.

import.meta.env with guarded process.env fallback

Section titled “import.meta.env with guarded process.env fallback”

Vite transforms import.meta.env.* at build time for browser code, but Netlify Functions runtime does not expose site environment variables through import.meta.env. All packages that read custom env vars use:

import.meta.env?.[key] ?? (typeof process !== "undefined" ? process.env?.[key] : undefined) ?? ""

The typeof process guard is required because portal.config.mjs and its imports can be loaded in the browser during editor hydration, where process is undefined. Accessing process.env without the guard throws a ReferenceError. Any new env() access in a @drawnagency/* package must include this guard.

isomorphic-dompurify removed — lazy browser-only dompurify

Section titled “isomorphic-dompurify removed — lazy browser-only dompurify”

The original implementation used isomorphic-dompurify, which pulls in jsdom. jsdom’s CJS/ESM dependency chain has incompatibilities that break in any Node.js SSR runtime. The fix: load dompurify lazily and only in browser contexts. dexie has the same restriction. Both are in the browserOnlyDeps set in the integration and are excluded from noExternal (they are never bundled into the SSR output).

Tailwind 4 ignores node_modules/ by default. Client repos must add @source directives in their src/styles/base.css to enable Tailwind class scanning for the package source files:

@import "@drawnagency/core/styles/base.css";
@source "../../node_modules/@drawnagency/primitives/src/**/*.{ts,tsx}";
@source "../../node_modules/@drawnagency/core/src/**/*.{ts,tsx}";

Without these directives, Tailwind classes used in @drawnagency/* components are absent from the generated CSS.

In production builds, Rollup bundles everything and handles CJS-to-ESM conversion. In dev mode, Vite serves modules individually. Files inside node_modules/ are served raw — no CJS conversion, no dep pre-bundling. Since @drawnagency/primitives source is aliased from node_modules/, its transitive dependencies (atlaskit, tiptap, dexie, etc.) would be served as raw CJS, breaking in the browser.

The fix: the Vite plugin creates symlinks in the project root:

.portal/primitives → node_modules/@drawnagency/primitives/src/
.portal/core → node_modules/@drawnagency/core/src/

All resolveId paths go through these symlinks. Vite sees paths without node_modules/ in them and treats them as source code — properly pre-bundling their dependencies. resolve.preserveSymlinks: true prevents Vite from resolving the symlinks back to their real paths.

Required client site setup for dev:

  • .npmrc with shamefully-hoist=true — makes transitive deps resolvable from the project root so Vite’s optimizer can find and pre-bundle them.
  • patches/bind-event-listener@3.0.0.patch — adds an ESM entry point to this CJS-only package (a dependency of atlaskit). The template includes this patch file.
  • .portal/ in .gitignore — the directory is auto-generated on pnpm install / dev server start.

Adding a new package with shared mutable state

Section titled “Adding a new package with shared mutable state”

If a new @drawnagency/* package has module-level singletons (like primitives’ registry or media provider), it needs the same .portal/ symlink treatment:

  1. Add a symlink for it in vite-plugin.ts (ensureSymlink(...))
  2. Add its export paths to the resolveId routing in vite-plugin.ts
  3. Add its package name to the noExternal list in integration.ts

Without this, Vite may create duplicate module instances — one from src/ via aliases, one from dist/ via package resolution — creating two independent singletons.