Remix as a Middle Layer: Patterns from Production

Data Engineering

Your Remix server isn't just a view layer. Here's how we use React Router v7 as a reverse proxy, data orchestrator, SEO engine, and boot loader between the browser and a Python API.

Riley Hilliard
Riley Hilliard
Director of High-Fives·Mar 31, 2026·12 min
Copied to clipboard

Most Remix tutorials stop at loaders and components. Here’s your route, here’s your data, here’s your JSX. Which is fine for getting started, but it undersells what the framework can actually do.

In production, our React Router v7 server handles reverse proxying, parallel data prefetching, cookie-driven SSR, SEO meta generation, consent management, and a boot sequence that wires up auth and analytics in the right order. One framework, doing the work of four or five separate tools.

OpenData is a “GitHub for datasets” platform. FastAPI backend in Python, React Router v7 frontend, DuckDB for analytical queries. The backend generates an OpenAPI spec, the frontend consumes it. Two separate processes that need to talk to each other through the browser.

This post walks through the patterns that emerged from building that connection layer. No theory, just production code. Here’s a live API call going through the system right now:

That request goes from this blog (Astro on Cloudflare) to the Remix frontend (Mac Mini) through a catch-all proxy to a FastAPI backend. The data comes back as JSON. The whole path is what this post is about.

The Proxy Nobody Expects

In development, Vite proxies API requests to the backend with three lines of config. In production, react-router-serve has no built-in proxy. Your frontend and backend are separate Docker containers. Browser requests to /v1/datasets need to reach the Python backend somehow.

The answer is a catch-all resource route. The $ in v1.$.tsx is React Router’s splat, matching /v1/anything. Both the loader (GET) and action (POST/PUT/DELETE) call the same forwarding function:

// app/routes/api/v1.$.tsx
const ALLOWED_REQUEST_HEADERS = new Set([
  "accept", "accept-encoding", "content-type",
  "authorization", "cookie", "user-agent",
  "x-forwarded-for", "if-none-match",
]);

const ALLOWED_RESPONSE_HEADERS = new Set([
  "content-type", "cache-control", "etag",
  "set-cookie", "x-ratelimit-limit",
  "x-ratelimit-remaining", "x-ratelimit-reset",
]);

async function forwardRequest(request: Request, pathname: string, search: string) {
  const apiUrl = new URL(pathname + search, BACKEND_URL);

  const headers = new Headers();
  request.headers.forEach((value, key) => {
    if (ALLOWED_REQUEST_HEADERS.has(key.toLowerCase())) {
      headers.set(key, value);
    }
  });

  const fetchOptions: RequestInit = { method: request.method, headers };

  if (request.body && request.method !== "GET" && request.method !== "HEAD") {
    fetchOptions.body = request.body;
    // @ts-expect-error duplex is needed for streaming request bodies
    fetchOptions.duplex = "half";
  }

  try {
    const response = await fetch(apiUrl, fetchOptions);
    const responseHeaders = new Headers();
    response.headers.forEach((value, key) => {
      if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
        responseHeaders.set(key, value);
      }
    });
    return new Response(response.body, {
      status: response.status, statusText: response.statusText,
      headers: responseHeaders,
    });
  } catch {
    return new Response(JSON.stringify({ error: "Backend unavailable" }), {
      status: 502, headers: { "content-type": "application/json" },
    });
  }
}

That’s the entire proxy. One file, ~90 lines. It replaces an nginx reverse proxy config, and it lives right next to the rest of your routing logic. Three design decisions worth calling out:

DecisionWhy
Explicit header allowlistshost would confuse backend routing. server and x-powered-by would leak that you’re running uvicorn/Python 3.12. The allowlist is a security boundary.
Streaming bodiesduplex: "half" streams POST bodies instead of buffering. TypeScript doesn’t know about this yet (@ts-expect-error).
502 fallbackDead backend returns clean JSON instead of crashing the frontend.

Two Paths to the Same Backend

SSR loaders don’t go through the proxy. They use serverApi, which resolves BACKEND_URL at runtime from environment variables. In Docker, this points to the backend’s internal address. The .server.ts suffix guarantees this code never leaks to the browser bundle. If you accidentally import it in a client component, the build fails.

// app/lib/server-api.server.ts
export const BACKEND_URL =
  process.env.BACKEND_URL ??         // Docker env var
  process.env.VITE_SSR_API_URL ??    // SSR-specific override
  "http://localhost:8000";            // Local dev default

export const serverApi = new ApiClient({ baseURL: BACKEND_URL });

The client-side api uses relative URLs (/v1/datasets) that hit the catch-all proxy. Two clients, same backend. Server talks directly (internal network), browser goes through the proxy (same domain, no CORS).

Generated Types from OpenAPI

The backend is FastAPI, which auto-generates an OpenAPI spec from Pydantic models. We commit that spec to the repo and run Orval to generate Zod schemas and TypeScript types. A custom transformer converts snake_case property names to camelCase before generation. One regex applied recursively through every schema. Backend stays Pythonic, frontend stays JavaScript-y.

bun generate:api runs the whole pipeline. Python model changes flow to TypeScript types with one command. No manual type duplication.

Loaders as Your Data Layer

With the proxy handling browser requests, loaders handle the SSR path. Every loader creates a fresh QueryClient, prefetches data, and dehydrates it for the client.

// app/lib/ssr-query.ts
export function createSSRQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,         // Fresh for 1 minute
        retry: false,                   // Don't retry on server
        refetchOnMount: false,          // Data is already fresh from SSR
        refetchOnWindowFocus: false,
        refetchOnReconnect: false,
      },
    },
  });
}

staleTime: 60s prevents the client from re-fetching data that the server just loaded. retry: false means SSR errors surface immediately instead of blocking the response with retries. Each loader creates its own QueryClient to prevent data from one request leaking into another.

Parallel Prefetching

The home page prefetches platform stats and featured datasets in a single Promise.all:

// app/routes/home/_index.tsx
export async function loader() {
  const queryClient = createSSRQueryClient();

  try {
    await Promise.all([
      queryClient.prefetchQuery({
        queryKey: queryKeys.platform.stats,
        queryFn: () => serverApi.get("/v1/stats"),
      }),
      queryClient.prefetchQuery({
        queryKey: queryKeys.search.results({ query: "", limit: 6 }),
        queryFn: () => serverApi.get("/v1/search", { limit: "6" }),
      }),
    ]);
  } catch {
    // API unavailable - client-side hooks will fetch
  }

  return { dehydratedState: dehydrate(queryClient) };
}

The try/catch is the important part. If the backend is down, the loader doesn’t crash. The page renders with empty state. Client-side React Query fires its own requests with normal retry logic. The user sees a loading spinner instead of instant data, but they never see an error page.

The component is just a thin wrapper around HydrationBoundary:

export default function HomePage({ loaderData }: Route.ComponentProps) {
  return (
    <HydrationBoundary state={loaderData?.dehydratedState}>
      <SignedOut><LandingPage /></SignedOut>
      <SignedIn><Dashboard /></SignedIn>
    </HydrationBoundary>
  );
}

No loading spinner, no double-fetch. The hooks inside LandingPage read the same query keys and immediately see the server-prefetched data.

Here’s a constraint that trips people up: SSR loaders can read cookies but not localStorage. If you store user preferences in localStorage, the server renders with the wrong default, the client hydrates, reads localStorage, switches to the right value, and you get a flash of wrong content.

Cookies solve this. One function, two modes:

// app/lib/preferences.ts
export function getPreferredSort(cookieHeader?: string): SortOption | null {
  const cookies =
    cookieHeader ?? (typeof document !== "undefined" ? document.cookie : "");
  return parseCookie(cookies, SORT_COOKIE) as SortOption | null;
}

Pass the raw Cookie header string from the request (SSR), or call with no argument and it reads document.cookie (client). The search page’s loader uses this to prefetch results with the user’s preferred sort order. First paint matches their preference.

The priority chain: URL param (explicit user action) beats cookie (remembered preference) beats default. Simple, but it prevents an entire class of hydration mismatches.

SEO That Falls Out of the Architecture

If your SSR is working, SEO is mostly a formatting exercise. The hard part (getting real data into the HTML) is already solved by loaders. Everything here is just shaping that data for crawlers.

Before and After

The difference between generic and data-driven meta descriptions:

Without enriched data
<title>cpi-u - bls - OpenData</title>
<meta name=“description” content=“Dataset details for cpi-u” />
No OG tags, no structured data, no canonical URL
With buildMeta + enriched data
<title>Consumer Price Index for All Urban Consumers - BLS - OpenData</title>
<meta name=“description” content=“Monthly inflation data tracking price changes…” />
<meta property=“og:title” content=“Consumer Price Index…” />
<link rel=“canonical” href=“https://tryopendata.ai/datasets/bls/cpi-u” />
<script type=“application/ld+json”>{“@type”: “Dataset”…}</script>
Full OG tags, Twitter card, canonical URL, Schema.org structured data
Same route, same code path. The loader just supplies better data from the enrichment pipeline.

Every route calls buildMeta() instead of hand-building 15 meta objects. Two lesser-known React Router v7 features make this work: { tagName: "link" } renders a <link> tag through the meta() export, and { "script:ld+json": data } renders structured data as <script type="application/ld+json">. Both from one function call.

The Rest of the SEO Stack

PieceHow it works
Dynamic sitemapResource route fetches datasets, providers, and categories in parallel. In-memory cache with 24-hour TTL. Individual .catch() calls so one API failure doesn’t kill the whole sitemap.
robots.txtEnvironment-aware resource route. Production allows public routes, blocks auth-gated stuff. Dev/staging blocks everything. Comments explain what’s blocked and why.
Pre-rendering14 routes rendered to static HTML at build time. ARG BACKEND_URL in the Dockerfile lets pre-rendering loaders fetch real API data during the Docker build. Landing page ships with actual stats baked in.
Cache headersPer-route headers() exports. Dataset pages: 5 min browser / 1 hour CDN. Sitemap: 24 hours both. Docs: pre-rendered, zero server cost.

The Boot Sequence

Everything above lives inside a carefully ordered provider stack in root.tsx.

The nesting order is not arbitrary. Swap any two and something breaks.

export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <ThemeProvider>           {/* Must be outermost - everything uses theme */}
      <PostHogProvider>       {/* Analytics wrapper (inert until init) */}
        <ConsentProvider>     {/* Gates PostHog init via onAccept */}
          <ClerkProvider>     {/* Auth context */}
            <QueryProvider>   {/* React Query cache */}
              <HydrationBoundary state={loaderData?.dehydratedState}>
                <AppErrorBoundary>
                  <Outlet />
                </AppErrorBoundary>
              </HydrationBoundary>
            </QueryProvider>
          </ClerkProvider>
        </ConsentProvider>
      </PostHogProvider>
    </ThemeProvider>
  );
}

Four things happening in this stack that took real debugging to get right:

Theme flash prevention. An inline <script> runs before React hydrates, reads localStorage, and sets data-theme on <html> immediately. Without this: server renders light, browser paints it, React hydrates, switches to dark, user sees a flash of white. suppressHydrationWarning on <html> tells React the mismatch is intentional.

Feature flags without analytics. The root loader fetches flags from /v1/config on our backend instead of calling PostHog’s /decide endpoint. Every /decide call is a billing event. By caching flags server-side and bootstrapping them into PostHog’s config, we skip the client-side call entirely. Flags work even for users who decline analytics consent.

Lazy PostHog (~100KB savings). We define window.__initPostHog() as a global callback but never call posthog.init() until consent is granted. The ConsentProvider calls it on accept, useFetcher POSTs the consent choice to an action route that sets a cross-domain cookie, and analytics starts. No page reload.

Cross-domain consent. Domain=.tryopendata.ai (leading dot) makes the consent cookie available on every subdomain. Accept consent on the blog, visit the main app, choice is already there.

Error Handling: Three Layers

Layer 1: SSR prefetch fails silently. The try/catch in every loader means a backend failure during SSR results in an empty cache. The page renders, client-side React Query retries. Loading spinner instead of instant data, but never an error page.

Layer 2: Route errors. useRouteError() catches loader and action failures. Each HTTP status code maps to a themed error UI. 404 gets a “not found” vibe with curated GIFs. 503 gets “overloaded.” 500+ get a retry button because the error might be transient.

Layer 3: React error boundary. A class component (still required by React) catches render errors, reports to Sentry with the component stack trace, and shows a recovery UI.

A small detail: the 404 route picks its random GIF in the loader (server-side) and passes it to the component. If getRandomGif() ran in the component, server and client would pick different GIFs and React would log a hydration mismatch.

Beyond Remix

Remix handles the interactive experience. But most of our traffic never gets there.

An Astro 5 static blog on Cloudflare Pages handles the content funnel. 13 posts with embedded interactive demos, charts, and live API examples. Free hosting, unlimited scale. A post that goes viral costs exactly $0.

Both apps import from a shared/ directory: theme provider, mobile nav, consent banner, PostHog config. Users can’t tell they switched apps. Cross-domain analytics track a single user identity from blog post to platform signup.

Remix (interactive)Astro (content)
JS shippedFull React runtimeZero by default
HostingMac Minis (SSR)Cloudflare CDN (free)
Use caseLoaders, actions, authMDX, RSS, content collections
CostScales with traffic$0 forever

The framework question is: what belongs on a server and what belongs on a CDN? Educational content is static. Interactive platform needs SSR with loaders, actions, and auth. Match the tool to the job.

The whole thing runs on two Mac Minis on a home network. Traefik load balancer, Docker Compose, three frontend replicas, two backend replicas, zero-downtime rolling deploys. Blog traffic offloaded entirely to Cloudflare. Keep costs near zero while finding product-market fit.

The Takeaway

Remix (React Router v7) occupies a useful middle ground between the browser and your API. The more you lean into what it can do in that space, the less custom infrastructure you need.

What Remix replacesHow
nginx proxy configCatch-all resource route (~90 lines)
Loading spinnersSSR loaders + React Query dehydration
localStorage hacksCookie-based preferences (SSR-readable)
Manual meta tagsbuildMeta() with JSON-LD + canonical URLs
Crash pagesThree error layers with graceful degradation

And when something doesn’t belong in Remix, use the right tool instead. Astro for content. Cloudflare for static hosting. A desk with two Mac Minis for everything else.


This post is a companion to a talk at Remix Austin on April 1, 2026. The OpenData repo is open source. Every file referenced here is in frontend/.

Riley HilliardRiley Hilliard

Director of High-Fives

At 13, I secretly drilled holes in my parents' wood floor to route a 56k modem line to my bedroom for late-night Age of Empires marathons. That same scrappy curiosity carried through 3 acquisitions, 9 years as a LinkedIn Staff Engineer building infrastructure for 1B+ users, and now fuels my side projects, like OpenData.

Copied to clipboard

More from OpenData

Curious about open data? Start exploring.

OpenData makes public datasets discoverable, consistently formatted, and queryable without the usual headaches.

Try it out
  • Browse thousands of public datasets
  • Query any dataset with a simple API
  • Download as CSV, JSON, or Parquet