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

  1. Browsers cannot resolve internal service names: URLs like http://spatial or http://catalog are internal to the Docker/Aspire network
  2. Dynamic ports are not exposed to the host: Aspire assigns ports dynamically, and browsers cannot access these internal ports
  3. CORS complexity: Direct browser-to-backend calls require CORS configuration on every backend service
  4. Security concerns: Exposing all backend services directly to browsers increases the attack surface
  5. 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:8080 which 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

  1. Same-Origin Requests: Browser only communicates with the Next.js server (no CORS issues)
  2. Server-Side Service Discovery: Next.js API routes use Aspire service discovery to reach backends
  3. Single Proxy Implementation: One catch-all route handles all backend services
  4. Works with Dynamic Ports: No hardcoded ports, fully compatible with Aspire orchestration
  5. 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_BASEURLhttp://spatial:8080 (injected by Aspire)
  • SERVICE_CATALOG_BASEURLhttp://catalog:8081 (injected by Aspire)
  • SERVICE_IDENTITY_BASEURLhttp://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:

  1. Update resolveApiBaseUrl() in request adapter to return /api/{service} for browser
  2. Create the catch-all proxy route at /pages/api/[service]/[...path].ts
  3. Remove NEXT_PUBLIC_SERVICE_*_BASEURL injection from orchestration
  4. Test all API calls work through the proxy
  5. Remove any CORS configuration from backend services (no longer needed)
  • 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


Date: 2025-11-13
Status: Accepted
Decision Makers: Architecture Team
Stakeholders: Frontend Developers, Backend Developers, DevOps Team