diff --git a/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx b/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx
index b854691e5b8..3e08f46fe53 100644
--- a/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx
+++ b/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx
@@ -13,7 +13,7 @@ export default async function handleRequest(
,
{
signal: request.signal,
- onError(error) {
+ onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
diff --git a/packages/remix-dev/config/defaults/entry.server.deno.tsx b/packages/remix-dev/config/defaults/entry.server.deno.tsx
index 96ff812aa2f..7be6c748061 100644
--- a/packages/remix-dev/config/defaults/entry.server.deno.tsx
+++ b/packages/remix-dev/config/defaults/entry.server.deno.tsx
@@ -13,7 +13,7 @@ export default async function handleRequest(
,
{
signal: request.signal,
- onError(error) {
+ onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
diff --git a/packages/remix-dev/config/defaults/entry.server.node.tsx b/packages/remix-dev/config/defaults/entry.server.node.tsx
index 98b32eb3867..469de6d4329 100644
--- a/packages/remix-dev/config/defaults/entry.server.node.tsx
+++ b/packages/remix-dev/config/defaults/entry.server.node.tsx
@@ -5,7 +5,7 @@ import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
-const ABORT_DELAY = 5000;
+const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
diff --git a/templates/arc/app/entry.client.tsx b/templates/arc/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/arc/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/arc/app/entry.server.tsx b/templates/arc/app/entry.server.tsx
new file mode 100644
index 00000000000..0565fb5bc9a
--- /dev/null
+++ b/templates/arc/app/entry.server.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ 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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/templates/arc/package.json b/templates/arc/package.json
index cb0485a1664..c0697540150 100644
--- a/templates/arc/package.json
+++ b/templates/arc/package.json
@@ -14,6 +14,7 @@
"@remix-run/node": "*",
"@remix-run/react": "*",
"cross-env": "^7.0.3",
+ "isbot": "^3.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
diff --git a/templates/cloudflare-pages/app/entry.client.tsx b/templates/cloudflare-pages/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/cloudflare-pages/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/cloudflare-pages/app/entry.server.tsx b/templates/cloudflare-pages/app/entry.server.tsx
new file mode 100644
index 00000000000..361b4560500
--- /dev/null
+++ b/templates/cloudflare-pages/app/entry.server.tsx
@@ -0,0 +1,38 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import type { EntryContext } from "@remix-run/cloudflare";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToReadableStream } from "react-dom/server";
+
+export default async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ const body = await renderToReadableStream(
+ ,
+ {
+ signal: request.signal,
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ if (isbot(request.headers.get("user-agent"))) {
+ await body.allReady;
+ }
+
+ responseHeaders.set("Content-Type", "text/html");
+ return new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+}
diff --git a/templates/cloudflare-workers/app/entry.client.tsx b/templates/cloudflare-workers/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/cloudflare-workers/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/cloudflare-workers/app/entry.server.tsx b/templates/cloudflare-workers/app/entry.server.tsx
new file mode 100644
index 00000000000..361b4560500
--- /dev/null
+++ b/templates/cloudflare-workers/app/entry.server.tsx
@@ -0,0 +1,38 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import type { EntryContext } from "@remix-run/cloudflare";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToReadableStream } from "react-dom/server";
+
+export default async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ const body = await renderToReadableStream(
+ ,
+ {
+ signal: request.signal,
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ if (isbot(request.headers.get("user-agent"))) {
+ await body.allReady;
+ }
+
+ responseHeaders.set("Content-Type", "text/html");
+ return new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+}
diff --git a/templates/deno/app/entry.client.tsx b/templates/deno/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/deno/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/deno/app/entry.server.tsx b/templates/deno/app/entry.server.tsx
new file mode 100644
index 00000000000..5354bb7fb2e
--- /dev/null
+++ b/templates/deno/app/entry.server.tsx
@@ -0,0 +1,38 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import type { EntryContext } from "@remix-run/deno";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToReadableStream } from "react-dom/server";
+
+export default async function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ const body = await renderToReadableStream(
+ ,
+ {
+ signal: request.signal,
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ if (isbot(request.headers.get("user-agent"))) {
+ await body.allReady;
+ }
+
+ responseHeaders.set("Content-Type", "text/html");
+ return new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+}
diff --git a/templates/express/app/entry.client.tsx b/templates/express/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/express/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/express/app/entry.server.tsx b/templates/express/app/entry.server.tsx
new file mode 100644
index 00000000000..0565fb5bc9a
--- /dev/null
+++ b/templates/express/app/entry.server.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ 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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/templates/fly/app/entry.client.tsx b/templates/fly/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/fly/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/fly/app/entry.server.tsx b/templates/fly/app/entry.server.tsx
new file mode 100644
index 00000000000..0565fb5bc9a
--- /dev/null
+++ b/templates/fly/app/entry.server.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ 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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/templates/netlify/app/entry.client.tsx b/templates/netlify/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/netlify/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/netlify/app/entry.server.tsx b/templates/netlify/app/entry.server.tsx
new file mode 100644
index 00000000000..0565fb5bc9a
--- /dev/null
+++ b/templates/netlify/app/entry.server.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ 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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/templates/netlify/package.json b/templates/netlify/package.json
index b8f1f93ee16..72faa279bb1 100644
--- a/templates/netlify/package.json
+++ b/templates/netlify/package.json
@@ -13,6 +13,7 @@
"@remix-run/node": "*",
"@remix-run/react": "*",
"cross-env": "^7.0.3",
+ "isbot": "^3.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
diff --git a/templates/remix/app/entry.client.tsx b/templates/remix/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/remix/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/remix/app/entry.server.tsx b/templates/remix/app/entry.server.tsx
new file mode 100644
index 00000000000..0565fb5bc9a
--- /dev/null
+++ b/templates/remix/app/entry.server.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ 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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/templates/vercel/app/entry.client.tsx b/templates/vercel/app/entry.client.tsx
new file mode 100644
index 00000000000..3e8ec03d450
--- /dev/null
+++ b/templates/vercel/app/entry.client.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/templates/vercel/app/entry.server.tsx b/templates/vercel/app/entry.server.tsx
new file mode 100644
index 00000000000..0565fb5bc9a
--- /dev/null
+++ b/templates/vercel/app/entry.server.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "stream";
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ 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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/templates/vercel/package.json b/templates/vercel/package.json
index 46ba5732c9f..5ae229ae25c 100644
--- a/templates/vercel/package.json
+++ b/templates/vercel/package.json
@@ -11,6 +11,7 @@
"@remix-run/react": "*",
"@remix-run/vercel": "*",
"@vercel/node": "^2.6.2",
+ "isbot": "^3.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},