Documentation
adrs/039-nextjs-api-proxy-pattern.md
ADR-039: Next.js API Proxy Pattern for Browser-to-Backend Communication
Status
Accepted
Context
In the Dynaplex architecture using .NET Aspire, backend microservices are registered with dynamic ports and internal service names (e.g., http://spatial:XXXX, http://catalog:YYYY). These URLs work perfectly for server-to-server communication within the Aspire orchestration network, but present challenges for browser-to-backend communication:
The Problem
- Browsers cannot resolve internal service names: URLs like
http://spatialorhttp://catalogare internal to the Docker/Aspire network - Dynamic ports are not exposed to the host: Aspire assigns ports dynamically, and browsers cannot access these internal ports
- CORS complexity: Direct browser-to-backend calls require CORS configuration on every backend service
- Security concerns: Exposing all backend services directly to browsers increases the attack surface
- Violates Golden Rule #6: Hardcoding fixed ports for each service breaks Aspire's dynamic port philosophy
What We Tried Initially
We attempted to inject NEXT_PUBLIC_SERVICE_*_BASEURL environment variables with the internal Aspire URLs, expecting browsers to use them. This failed because:
- Browsers received URLs like
http://spatial:8080which they cannot resolve - Even if they could resolve the hostnames, the ports aren't accessible from the host machine
- This resulted in CORS errors: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource"
Decision
We implement Next.js API Routes as a Backend Proxy - a lightweight, same-origin proxy that bridges the gap between the browser and Aspire service discovery.
Architecture
Browser → /api/{service}/{path} (Same-origin request, no CORS)
↓
Next.js API Route (Server-side, has access to Aspire network)
↓
http://{service}:XXXX/{path} (Uses Aspire service discovery)
↓
Backend Service
Implementation
1. Generic Catch-All Proxy Route (/pages/api/[service]/[...path].ts):
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { service, path } = req.query;
// Get service URL from Aspire service discovery (server-side)
const serviceBaseUrl = process.env[`SERVICE_${normalize(service)}_BASEURL`];
const targetUrl = `${serviceBaseUrl}/${path.join('/')}`;
// Get auth from session (no HTTP request needed)
const session = await getServerSession(req, res, authOptions);
// Forward request to backend service
const response = await fetch(targetUrl, {
method: req.method,
headers: {
'Authorization': `Bearer ${session?.accessToken}`,
'Content-Type': req.headers['content-type']
},
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined
});
// Return response to browser
res.status(response.status).json(await response.json());
}
2. Browser-Side Request Adapter (lib/kiota/requestAdapter.ts):
const resolveApiBaseUrl = (serviceKey?: string): string => {
// Server-side: Use Aspire service discovery
if (typeof window === "undefined") {
return process.env[`SERVICE_${normalize(serviceKey)}_BASEURL`] ?? "http://localhost:8080";
}
// Browser-side: Use Next.js API proxy
return serviceKey ? `/api/${serviceKey}` : "/api/catalog";
};
3. Aspire Orchestration (DynaplexNextJsExtensions.cs):
// Only inject server-side service discovery URLs
// Browser uses Next.js API proxy routes instead
resource = resource.WithEnvironment(envVarName, endpoint)
.WithReference(dependency)
.WaitFor(dependency);
Key Principles
- Same-Origin Requests: Browser only communicates with the Next.js server (no CORS issues)
- Server-Side Service Discovery: Next.js API routes use Aspire service discovery to reach backends
- Single Proxy Implementation: One catch-all route handles all backend services
- Works with Dynamic Ports: No hardcoded ports, fully compatible with Aspire orchestration
- Centralized Auth: Session management happens once in the proxy, not in each backend call
Consequences
Positive
✅ No CORS issues: Same-origin requests from browser to Next.js
✅ Works with dynamic ports: Fully compatible with Aspire orchestration
✅ Centralized authentication: Auth token extracted once in the proxy
✅ Security: Backend services hidden from direct browser access
✅ Maintainability: Single proxy implementation for all services
✅ Standard Next.js pattern: Follows documented Next.js best practices
✅ Observable: Can add logging/tracing in one place
✅ Testable: Easy to test proxy behavior independently
Negative
⚠️ Additional latency: Extra network hop (browser → Next.js → backend vs direct)
⚠️ Server load: Next.js server handles more traffic
⚠️ Not for non-browser clients: External API clients still need direct service access
Neutral
ℹ️ Complementary to ADR-031: This pattern handles UI-specific communication; a full YARP gateway could still be added for service-to-service or external API access
ℹ️ Tech stack specific: Solution is specific to Next.js; other UI frameworks need similar patterns
Implementation Details
Environment Variables
Server-Side (Next.js):
SERVICE_SPATIAL_BASEURL→http://spatial:8080(injected by Aspire)SERVICE_CATALOG_BASEURL→http://catalog:8081(injected by Aspire)SERVICE_IDENTITY_BASEURL→http://identity:8082(injected by Aspire)
Browser-Side:
- No service-specific URLs injected
- Uses
/api/{service}/*for all backend communication
Request Flow Examples
Example 1: Loading Regions
1. Browser: GET /api/spatial/regions
2. Next.js Proxy: GET http://spatial:8080/regions (with auth token)
3. Spatial Service: Returns region data
4. Next.js Proxy: Returns data to browser
5. Browser: Receives data as same-origin response
Example 2: Creating an Asset
1. Browser: POST /api/catalog/assets with JSON body
2. Next.js Proxy: POST http://catalog:8081/assets (with auth token + body)
3. Catalog Service: Creates asset, returns 201 Created
4. Next.js Proxy: Returns 201 to browser
5. Browser: Receives success response
Error Handling
The proxy provides clear error messages:
- 503 Service Unavailable: Backend service not reachable (service discovery failed)
- 401 Unauthorized: No valid session (redirects to login)
- 5XX errors: Proxied directly from backend with original status codes
Performance Considerations
Latency: Adds approximately 5-15ms of latency for the extra hop
Throughput: Next.js can handle thousands of requests per second
Caching: Can add response caching at proxy level if needed
Connection Pooling: HTTP client in Next.js maintains connection pools to backends
Alternatives Considered
Alternative 1: Expose Each Service on Fixed Ports
Rejected because:
- Violates Golden Rule #6 (no hardcoded ports)
- Breaks Aspire's dynamic port assignment
- Port conflicts in development environments
- Requires CORS configuration on all services
- Not cloud-native or scalable
Alternative 2: YARP-Based Full API Gateway (ADR-031)
Not chosen for UI because:
- Overkill for simple UI-to-backend communication
- Adds another infrastructure component to manage
- Next.js proxy is simpler and integrated into the UI layer
- YARP gateway still valuable for service-to-service or external API access
Alternative 3: Direct Service Discovery in Browser
Not possible because:
- Browsers cannot resolve internal Docker/Aspire hostnames
- Internal service ports not exposed to host machine
- Would require significant infrastructure changes to Aspire
Migration Notes
When migrating existing UI code to this pattern:
- Update
resolveApiBaseUrl()in request adapter to return/api/{service}for browser - Create the catch-all proxy route at
/pages/api/[service]/[...path].ts - Remove
NEXT_PUBLIC_SERVICE_*_BASEURLinjection from orchestration - Test all API calls work through the proxy
- Remove any CORS configuration from backend services (no longer needed)
Related ADRs
- ADR-007: Aspire Microservices Migration - Foundation for service orchestration
- ADR-031: API Gateway Pattern (Proposed) - Complementary pattern for service-to-service communication
- ADR-033: Base Orchestration Pattern - How components are registered and wired in Aspire
References
- Next.js API Routes Documentation
- .NET Aspire Service Discovery
- Aspire Service-to-Service Communication
Date: 2025-11-13
Status: Accepted
Decision Makers: Architecture Team
Stakeholders: Frontend Developers, Backend Developers, DevOps Team