Skip to content

Research: Onboarding Subdomain — Deeper Investigation

Scope: Issue #210 — extract onboard.neemias.app from the main SPA. Date: 2026-07-13 Sources: Vite docs (vitejs.dev), pnpm docs (pnpm.io), Cloudflare Pages docs (developers.cloudflare.com), Turborepo docs, GitHub real-world examples, Stack Overflow, community discussions.


Primary Source Findings

1. Vite CLI: --config Is First-Class

Source: Vite CLI docs and Vite CLI source

Vite --config flag works for both dev server and build:

bash
# Dev
vite --config vite.onboarding.config.ts

# Build
vite build --config vite.onboarding.config.ts

# Preview
vite preview --config vite.onboarding.config.ts

"Users can explicitly specify a configuration file using the --config CLI option, which resolves the path relative to the current working directory."

Key insight: --config is the intended mechanism for multiple builds. It's not a hack.

Vite config is a regular JS/TS module — you can import from a shared config:

ts
// vite.shared.ts
import { defineConfig } from "vite";
export const sharedConfig = defineConfig({
  plugins: [react()],
  resolve: {
    alias: { "@": "/src" },
  },
});
ts
// vite.onboarding.config.ts
import { defineConfig } from "vite";
import { sharedConfig } from "./vite.shared";

export default defineConfig({
  ...sharedConfig,
  build: {
    rollupOptions: {
      input: "onboarding-index.html",
    },
    outDir: "dist-onboarding",
  },
});

This pattern is validated by the Vite plugin config hook and used in real projects (Vite Ruby, Symfony Vite).

2. Why rollupOptions.input Must Be a Separate HTML File

Source: Vite build source — input resolution

Vite resolves the entry point from rollupOptions.input (or falls back to index.html). The entry must be an HTML file — Vite's HTML plugin reads it, discovers <script> tags, and uses those as the JS entry points.

Not a library mode casebuild.lib mode produces format outputs (ESM/CJS/UMD), not a deployable SPA. The onboarding is a full SPA, so it needs its own HTML entry, not library mode.

3. emptyOutDir Gotcha

Source: Stack Overflow, Vite build docs

When chaining vite build --config A && vite build --config B, the second build clears the output directory of the first by default if they share the same output parent.

Fix: Either:

  • Use different outDir (e.g. dist vs dist-onboarding)
  • Or set build.emptyOutDir: false on one config
ts
// vite.onboarding.config.ts
export default defineConfig({
  build: {
    outDir: "dist-onboarding",  // Separate directory — no conflict
    emptyOutDir: true,          // Safe: only empties dist-onboarding/
  },
});

4. Cloudflare Pages Monorepo — Exact Configuration

Source: Cloudflare Pages Build Configuration and Monorepos

Cloudflare Pages supports monorepos with per-project settings:

SettingMain SPAOnboarding
Project nameneemias-appneemias-onboarding
Root directoryapp/app/
Build commandpnpm build:apppnpm build:onboarding
Build outputdistdist-onboarding
Domainapp.neemias.apponboard.neemias.app

"You have the option to vary the build command and/or root directory of your project to tell Pages where you would like your build command to run."

Both projects point to the same root directory (app/), because:

  • They share package.json and node_modules
  • The build commands differ (different Vite configs)
  • The output directories differ (different outDir)

Build watch paths (avoid unnecessary rebuilds):

yaml
# Main SPA — only rebuild when app/ files change (excluding onboarding)
include: ["app/src/app/", "app/src/modules/", "app/index.html", "app/vite.config.ts"]

# Onboarding — only rebuild when onboarding files change
include: ["app/src/modules/onboarding/", "app/onboarding-index.html", "app/vite.onboarding.config.ts"]

Limit: Cloudflare Pages supports up to 5 projects per repository.

5. Real-World Projects Using Two Vite Configs

a) Symfony Vite Bundle (Pentatrion)

Source: symfony-vite.pentatrion.com

Has an official guide for "multiple configurations": vite.config1.config.js + vite.config2.config.js, building to separate outDir with separate base paths. They run dev servers concurrently via concurrently:

json
"scripts": {
  "dev": "concurrently \"vite -c vite.config1.config.js\" \"vite -c vite.config2.config.js\"",
  "build": "vite build -c vite.config1.config.js && vite build -c vite.config2.config.js"
}

b) Vite Ruby (ElMassimo/vite_ruby)

Source: GitHub Discussion #496

Production app using 6 different builds for admin, main, client-portal, etc. Approach endorsed by maintainer:

"Should be easy to achieve if you use a custom binstub that can set the --config flag for Vite accordingly."

c) Stack Overflow consensus

Source: Stack Overflow

Accepted answer:

json
"build": "tsc && vite build --config vite.config.lib.dev.ts && vite build --config vite.config.lib.prod.ts"

Multiple configs chained with && is the standard pattern.

d) Single-SPA ecosystem

Source: single-spa.js.org

Micro-frontend orchestration uses independent Vite builds per micro-app, each with its own config. This validates the pattern of multiple Vite builds from a single repository.

6. pnpm Workspace — No Changes Needed

Source: pnpm workspace YAML

The current pnpm-workspace.yaml lists "app" explicitly. The two-config approach doesn't need to add a new workspace entry because both builds share the same package.json.

packages:
  - "app"
  - "packages/*"
  - "workers"
  - "docs"

This stays exactly the same.

7. Turborepo — Canonical apps/* Structure

Source: Turborepo Handbook

Turborepo's standard: applications in apps/, libraries in packages/. If this repo used Turborepo, the canonical layout would be:

apps/
  app/          # main SPA
  onboarding/   # separate Vite project
packages/
  schemas/
  permissions/

But Turborepo isn't in use, so this isn't a constraint. The two-config approach is valid as a middle ground before committing to full app separation.

8. Custom Domain Setup on Cloudflare Pages

Source: Cloudflare Pages Custom Domains and Workers Custom Domains

Steps to set up onboard.neemias.app:

  1. Go to Workers & Pages in Cloudflare Dashboard
  2. Select the neemias-onboarding project
  3. Go to Settings > Custom domains > Add custom domain
  4. Enter onboard.neemias.app
  5. Cloudflare auto-creates the DNS CNAME record

No WAF/rate-limit config needed at the Pages level — the API already has rate limiting on api.neemias.app. Optional: add WAF rule for onboard.neemias.app with rate limit of 30 req/min per IP.


Concrete Implementation Plan

File changes

app/
  ├── index.html                    (unchanged)
  ├── onboarding-index.html         NEW — entry for onboarding build
  ├── vite.config.ts                (unchanged)
  ├── vite.onboarding.config.ts     NEW
  ├── vite.shared.ts                NEW — shared config (optional)
  └── src/
      ├── main.tsx                  (unchanged — main SPA entry)
      └── onboarding/
          ├── main.tsx              NEW — onboarding SPA entry
          ├── OnboardingPage.tsx    MOVE from modules/onboarding/
          └── components/           MOVE from modules/onboarding/components/

onboarding-index.html

html
<!DOCTYPE html>
<html lang="pt-BR">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Cadastro — Neemias</title>
    <link rel="icon" href="/favicon.ico" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/onboarding/main.tsx"></script>
  </body>
</html>

vite.onboarding.config.ts

ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  root: ".",
  build: {
    rollupOptions: {
      input: "onboarding-index.html",
    },
    outDir: "dist-onboarding",
  },
});

src/onboarding/main.tsx

tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
import OnboardingPage from "./OnboardingPage";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/:hash" element={<OnboardingPage />} />
      </Routes>
    </BrowserRouter>
  </StrictMode>
);

package.json scripts

json
{
  "scripts": {
    "dev": "vite",
    "dev:onboarding": "vite --config vite.onboarding.config.ts",
    "build": "vite build",
    "build:onboarding": "vite build --config vite.onboarding.config.ts",
    "preview:onboarding": "vite preview --config vite.onboarding.config.ts"
  }
}

Cloudflare Pages Project 2 Configuration

SettingValue
Project nameneemias-onboarding
Root directoryapp/
Build commandpnpm build:onboarding
Build output directorydist-onboarding
Production branchmain
Custom domainonboard.neemias.app
Build watch paths (include)app/src/onboarding/**, app/onboarding-index.html, app/vite.onboarding.config.ts, packages/schemas/**

Risk Assessment Matrix

RiskLikelihoodImpactMitigation
Shared react-router v7 upgrade breaks both SPAsLowMediumKeep onboarding-index.html minimal dependencies
CSS/Tailwind class conflictsLowLowEach build tree-shakes independently
import.meta.env.VITE_BACKEND_URL differencesLowLowBoth SPAs hit same API; no cross-env concerns
Onboarding needs its own dependencies laterMediumLowMigrate to apps/onboarding/ at that point
Cloudflare Pages max project limit (5)LowHighOnly 2 projects now; 3 spare slots

Conclusion

The research confirms the Two-Config approach is:

  1. Documented — Vite docs explicitly support --config for multiple builds
  2. Validated by real projects — Symfony Vite, Vite Ruby, Single-SPA, and Stack Overflow patterns
  3. Fully supported by Cloudflare Pages — root directory + build command per project
  4. No pnpm changes needed — same package.json, same workspace entry
  5. Low migration cost — if needed later, move to apps/onboarding/ by adding package.json + updating pnpm-workspace.yaml

The separate-project approach (apps/onboarding/) is the correct long-term architecture and matches Turborepo/pnpm conventions. But it adds complexity (duplicate package.json, tsconfig.json, Tailwind config, CI overhead) without benefit at Neemias's current scale.


Sources

Distribuído sob licença MIT.