cohosted-frontend-backend
Separate Development, Co-Hosted Deployment
Overview
The frontend and backend run in the same process (or the same container), with requests routed by URL prefix or route priority. Each side iterates independently during development and ships as a single artifact for deployment.
When to Use
- The project needs a frontend UI but you don't want to maintain two deployment pipelines
- Solo or small-team project where you want to simplify operations
- You already have a backend API and need to add frontend pages under the same domain (avoiding CORS)
- You need SSR for the first paint or a skeleton screen, then let the client-side SPA take over
Core Architecture Pattern
Browser Request
│
▼
Single Server Process (Node.js or ASP.NET Core)
│
├─ /api/* → Backend API routes (matched first)
├─ /graphql → GraphQL endpoint (if any)
├─ /_next/* or static → Frontend static assets (CSS/JS/images)
├─ /portal/* or /next/* → Frontend page routes
└─ fallback → Serve frontend index.html (SPA route catch-all)
Key principle: backend routes always take priority over the frontend fallback.
Decision Tree: Choosing an Integration Approach
Before implementation, choose the right approach for your project:
1. What is the backend framework?
Node.js (Fastify/Express/Egg.js/Koa)
→ Option A: Plugin integration (recommended)
→ Option B: Serve build output + 404 Fallback
ASP.NET Core
→ Option C: Static export + MapFallbackToFile (recommended)
→ Option D: Reverse proxy to a standalone Next.js process (complex scenarios)
Other (Go/Java/Python)
→ The approach from Option C is universally applicable
2. Does the frontend need SSR?
No (most scenarios)
→ Use Next.js output: 'export' for static export
→ Zero runtime dependencies; the backend only needs to serve static files
Yes — SSR for the first paint + client-side takeover
→ Node.js: Use @fastify/nextjs or Egg.js view templates
→ .NET: Standalone Next.js process + YARP reverse proxy
Server-rendered skeleton + SPA replacement
→ Use Pug/EJS/Razor to render an HTML skeleton
→ Inject the frontend build output's <script> and <link> tags into the HTML
→ After frontend loads, mount to #root and replace the skeleton content
3. Frontend routing strategy
basePath isolation (recommended starting point)
→ Frontend lives under /next, /portal, /app, etc.
→ No conflict with existing backend routes
→ Allows gradual migration; remove the prefix later
Root path takeover (end goal)
→ Frontend handles all unmatched routes
→ Backend API distinguished by /api/* prefix
→ Must ensure the fallback has the lowest priority
Implementation Steps (General Flow)
Step 1: Initialize the Frontend Project
Create a frontend subdirectory inside the backend project:
# Node.js project
project-root/
├── app.js (or server.js)
├── pages/ ← Next.js pages (if using plugin integration)
└── client-app/ ← Standalone frontend project (if static export)
# ASP.NET Core project
src/Web/
├── Program.cs
├── Pages/ ← Razor Pages (keep as-is)
├── wwwroot/ ← Static assets root
│ └── portal/ ← Frontend build output directory
└── ClientApp/ ← Next.js source code
Frontend project initialization checklist:
- Use the project's unified package manager (pnpm/yarn/npm)
- Configure TypeScript
- Configure Tailwind CSS or the project's existing styling solution
- Set
basePathto match the URL prefix agreed upon with the backend - If using static export, set
output: 'export'andtrailingSlash: true
Step 2: Configure Backend Routing
Principle: when adding frontend support, only add a fallback at the end of the routing pipeline — do not modify existing routes.
Node.js serving static files + fallback:
// Register static file middleware (pointing to the build output directory)
fastify.register(require('@fastify/static'), {
root: path.join(__dirname, 'dist'),
prefix: '/portal/',
})
// Fallback unmatched routes to index.html
fastify.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/portal')) {
reply.sendFile('index.html', path.join(__dirname, 'dist'))
}
})
ASP.NET Core serving static files + fallback:
app.UseStaticFiles(); // Already present, no changes needed
// Add fallback after all routes
app.MapFallback("/portal/{**slug}", async context => {
context.Response.ContentType = "text/html";
await context.Response.SendFileAsync(
Path.Combine(app.Environment.WebRootPath, "portal", "index.html"));
});
Step 3: Update Build & Docker
Multi-stage Docker build template:
# ---- Frontend Build ----
FROM node:22-alpine AS frontend
WORKDIR /app
COPY path/to/ClientApp/ ./
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile && pnpm build
# ---- Backend Build ----
FROM <backend-base-image> AS backend
# ... existing backend build steps ...
# Copy frontend output to the static assets directory before publishing
COPY /app/out/ <static-assets-dir>/
# ---- Runtime ----
FROM <runtime-base-image>
# Final image has no Node.js — only the backend runtime + static files
Step 4: Configure the Development Experience
- Frontend
pnpm devruns independently on port 3000 - Backend
dotnet runornode app.jsruns on its own port - During frontend development, point to the backend API via environment variables (or configure a dev proxy)
- In CI/CD, build the frontend first, then the backend
Step 5: Update .gitignore
# Frontend build output (generated during CI/Docker builds)
ClientApp/node_modules/
ClientApp/.next/
ClientApp/out/
wwwroot/portal/ # or the corresponding output directory
SSR Skeleton → SPA Takeover Pattern (Detailed)
This is a special pattern where the server first renders an HTML skeleton so users see content immediately, then the frontend JS takes over the entire page once loaded.
How it works:
1. Server renders HTML using a template engine (Pug/EJS/Razor)
2. HTML contains <div id="root">skeleton content</div>
3. Frontend build output's <script src="app.js"> is injected at the bottom
4. Browser:
a. Immediately renders skeleton HTML (user sees the first paint; very fast FCP)
b. Downloads and executes app.js
c. React/Vue mounts to #root, replacing the skeleton content
d. Client-side routing takes over; subsequent navigations don't hit the server
Difference from Next.js SSR hydration:
- Hydration: the server renders the exact same React component tree as the client; the client "activates" event listeners without replacing the DOM
- Skeleton replacement: the server renders simple placeholder HTML (may not contain React components); the client replaces it entirely
Skeleton replacement is simpler but can cause a flash; suitable for admin dashboards with low SEO requirements. Hydration has no flash but is more complex to implement.
Security Considerations
- Frontend static assets must not contain sensitive information (API keys, database connection strings)
- Only put public information in
NEXT_PUBLIC_prefixed environment variables - API authentication is handled by backend route middleware — do not rely on the frontend
- If the backend has CSRF protection, ensure the frontend requests carry the correct token
- Frontend basePath routes must not bypass the backend's authentication middleware
- The static file directory must not expose
.env,node_modules, or other sensitive content
Reference Implementations
The following are verified real-world implementations covering different tech stacks. Read the corresponding reference files when you need specific code examples:
references/node-fastify-nextjs.md— Fastify + Next.js plugin integration (Jeff-Tian/mp)references/node-eggjs-umi.md— Egg.js + Umi/React skeleton replacement pattern (Jeff-Tian/alpha)references/dotnet-nextjs-static-export.md— ASP.NET Core + Next.js static export (Jeff-Tian/Leg-Godt)references/dotnet-nextjs-identity-server.md— ASP.NET Core IdentityServer + Next.js static export (A Duende based solution)
Common Pitfalls
- Route conflicts: The frontend fallback overrides API 404 responses. Fix: limit the fallback to a specific prefix or check the Accept header.
- Missing basePath: Frontend internal links don't use basePath, causing 404s for assets. Fix: Next.js's
basePathconfig handles this automatically. - Wrong Docker build order: Frontend output not copied to the backend's static directory before packaging. Fix: ensure
COPY --from=frontendhappens before publish in the Dockerfile. - Dev environment inconsistency: Frontend dev server and backend port conflict. Fix: pin the frontend to port 3000, backend to a different port.
- Caching issues: Browser still uses old version after frontend update. Fix: Next.js uses content-hashed filenames by default; make sure you don't set an overly long Cache-Control.
Language Policy
- Detect user language
- Respond in same language
More from jeff-tian/agent-skills
oidc-integration
Plan and implement OIDC and OAuth 2.0 integration for React or TypeScript frontends and Java or Spring Boot backends. Use whenever the user mentions OIDC, OpenID Connect, OAuth login, SSO, PKCE, authorization code flow, refresh tokens, JWT or JWKS validation, login callback pages, protected routes, Keycloak, Auth0, IdentityServer, Authing, multi-provider auth, or "add login" and "integrate IdP" style requests even if they do not explicitly say OIDC.
9tdd
Use when implementing any feature or bugfix, before writing implementation code
6specflow-to-reqnroll
>-
3