diff --git a/msw/README.md b/msw/README.md
index ea9d32e9..da3a2797 100644
--- a/msw/README.md
+++ b/msw/README.md
@@ -21,8 +21,10 @@ You can read more about the use cases of MSW [here](https://mswjs.io/docs/#when-
## Relevant files
-- [mocks](./mocks/index.cjs) - registers the Node HTTP mock server
-- [handlers](./mocks/handlers.cjs) - describes the HTTP mocks
+- [server-side mocks](./app/mocks/node.ts) - registers the Node mock server
+- [client-side mocks](./app/mocks/browser.ts) - registers the browser (Worker) mock server
+- [handlers](./app/mocks/handlers.ts) - describes the HTTP mocks
+- [root](./app/root.tsx) - added script to expose the API_BASE environment variable to client-side
- [package.json](./package.json)
## Related Links
diff --git a/msw/app/entry.client.tsx b/msw/app/entry.client.tsx
new file mode 100644
index 00000000..703e399f
--- /dev/null
+++ b/msw/app/entry.client.tsx
@@ -0,0 +1,30 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from '@remix-run/react'
+import { startTransition, StrictMode } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+
+// if in dev mode, import the worker for msw integration and start the worker
+async function prepareApp() {
+ if (process.env.NODE_ENV === 'development') {
+ const { worker } = await import('./mocks/browser')
+ return worker.start()
+ }
+
+ return Promise.resolve()
+}
+
+prepareApp().then(() => {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ )
+ })
+})
diff --git a/msw/app/entry.server.tsx b/msw/app/entry.server.tsx
new file mode 100644
index 00000000..045252d6
--- /dev/null
+++ b/msw/app/entry.server.tsx
@@ -0,0 +1,148 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/file-conventions/entry.server
+ */
+
+import { PassThrough } from 'node:stream'
+
+import type { AppLoadContext, EntryContext } from '@remix-run/node'
+import { createReadableStreamFromReadable } from '@remix-run/node'
+import { RemixServer } from '@remix-run/react'
+import { isbot } from 'isbot'
+import { renderToPipeableStream } from 'react-dom/server'
+
+// import server for msw integration
+import { server } from './mocks/node'
+
+const ABORT_DELAY = 5_000
+
+// if in dev mode, start the node server
+if (process.env.NODE_ENV === 'development') {
+ server.listen()
+}
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+ // This is ignored so we can keep it in the template for visibility. Feel
+ // free to delete this parameter in your app if you're not using it!
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ loadContext: AppLoadContext,
+) {
+ return isbot(request.headers.get('user-agent') || '')
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext,
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext,
+ )
+}
+
+function handleBotRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ shellRendered = true
+ const body = new PassThrough()
+ const stream = createReadableStreamFromReadable(body)
+
+ responseHeaders.set('Content-Type', 'text/html')
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ }),
+ )
+
+ pipe(body)
+ },
+ onShellError(error: unknown) {
+ reject(error)
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error)
+ }
+ },
+ },
+ )
+
+ setTimeout(abort, ABORT_DELAY)
+ })
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ shellRendered = true
+ const body = new PassThrough()
+ const stream = createReadableStreamFromReadable(body)
+
+ responseHeaders.set('Content-Type', 'text/html')
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ }),
+ )
+
+ pipe(body)
+ },
+ onShellError(error: unknown) {
+ reject(error)
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error)
+ }
+ },
+ },
+ )
+
+ setTimeout(abort, ABORT_DELAY)
+ })
+}
diff --git a/msw/app/mocks/browser.ts b/msw/app/mocks/browser.ts
new file mode 100644
index 00000000..4dd03f09
--- /dev/null
+++ b/msw/app/mocks/browser.ts
@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw/browser'
+import { handlers } from './handlers'
+
+export const worker = setupWorker(...handlers)
diff --git a/msw/app/mocks/handlers.ts b/msw/app/mocks/handlers.ts
new file mode 100644
index 00000000..d616a9de
--- /dev/null
+++ b/msw/app/mocks/handlers.ts
@@ -0,0 +1,13 @@
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ // Intercept "GET ${process.env.API_BASE}/user" requests...
+ http.get(`${process.env.API_BASE}/user`, () => {
+ // ...and respond to them using this JSON response.
+ return HttpResponse.json({
+ id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3d',
+ firstName: 'John',
+ lastName: 'Maverick',
+ })
+ }),
+]
diff --git a/msw/app/mocks/node.ts b/msw/app/mocks/node.ts
new file mode 100644
index 00000000..86f7d615
--- /dev/null
+++ b/msw/app/mocks/node.ts
@@ -0,0 +1,4 @@
+import { setupServer } from 'msw/node'
+import { handlers } from './handlers'
+
+export const server = setupServer(...handlers)
diff --git a/msw/app/root.tsx b/msw/app/root.tsx
index e82f26fd..e74979c1 100644
--- a/msw/app/root.tsx
+++ b/msw/app/root.tsx
@@ -4,7 +4,29 @@ import {
Outlet,
Scripts,
ScrollRestoration,
-} from "@remix-run/react";
+} from '@remix-run/react'
+import './tailwind.css'
+
+/**
+ * Retrieves and stringifies specific environment variables for browser exposure.
+ *
+ * @function getBrowserEnvironment
+ * @returns {string} A JSON string containing the public environment variables.
+ *
+ * @note
+ * - Only variables listed in `exposedVariables` will be included in the output.
+ * - Do not add secret variables to the `exposedVariables` array.
+ */
+const getBrowserEnvironment = () => {
+ const exposedVariables = ['API_BASE']
+ const env = Object.keys(process.env)
+ .filter((key) => exposedVariables.includes(key))
+ .reduce((obj: Record, key) => {
+ obj[key] = process.env[key]!
+ return obj
+ }, {})
+ return JSON.stringify(env)
+}
export function Layout({ children }: { children: React.ReactNode }) {
return (
@@ -19,11 +41,16 @@ export function Layout({ children }: { children: React.ReactNode }) {
{children}
+