diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1c93ae8d..128d3b02 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,7 +10,7 @@ The repository is structured as follows:
 
 - `api`: The API server which is served via `https://api.docs.page`. This is an express application which handles tasks such as fetching content from GitHub and markdown parsing.
 - `og`: A Next.js application which serves the Open Graph images for documentation pages.
-- `website`: A Remix application which serves the main `https://docs.page` website, and the documentation rendering for each repository.
+- `website`: A Next.js application which serves the main `https://docs.page` website, and the documentation rendering for each repository.
 - `packages/cli`: A CLI for running various commands and scripts for initialization, checking etc. Used locally and on CI environments.
 
 ## Running docs.page
@@ -21,6 +21,6 @@ Generally, you'll want to interface with the website and api. To run these concu
 bun dev
 ```
 
-This will start the website on `http://localhost:5173` and the api on `http://localhost:8080`.
+This will start the website on `http://localhost:3000` and the api on `http://localhost:8080`.
 
 > The API requires a `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` to be set in your environment. These are used to authenticate with the GitHub API. You can create a GitHub App in your GitHub account settings.
\ No newline at end of file
diff --git a/api/src/config/v1.schema.ts b/api/src/config/v1.schema.ts
index f2cd52a9..cca68f91 100644
--- a/api/src/config/v1.schema.ts
+++ b/api/src/config/v1.schema.ts
@@ -78,10 +78,12 @@ export const V1ConfigSchema = z
     const config: Config = {
       name: v1.name,
       description: v1.description,
-      favicon: v1.favicon,
+      favicon: {
+        light: v1.favicon,
+        dark: v1.favicon,
+      },
       socialPreview: v1.socialPreview,
       logo: {
-        href: "/",
         light: v1.logo,
         dark: v1.logoDark,
       },
diff --git a/bun.lockb b/bun.lockb
index 59fb2a70..85f3007e 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/og/package.json b/og/package.json
index 626b3cc1..3dd12601 100644
--- a/og/package.json
+++ b/og/package.json
@@ -15,8 +15,8 @@
     "@types/react-dom": "18.0.10",
     "@vercel/og": "^0.0.21",
     "next": "13.1.0",
-    "react": "18.3.0-canary-bb0944fe5-20240313",
-    "react-dom": "18.2.0",
+    "react": "18.3.1",
+    "react-dom": "18.3.1",
     "typescript": "4.9.4"
   }
 }
diff --git a/package.json b/package.json
index 147f79d5..e9e73d81 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
     "dev": "concurrently \"npm run dev:api\" \"npm run dev:website\"",
     "dev:api": "cd api && bun dev",
     "dev:website": "cd website && npm run dev",
-    "check": "npx @biomejs/biome check --write ."
+    "check": "bunx @biomejs/biome check --write ."
   },
   "dependencies": {
     "typescript": "^5.5.3"
@@ -13,12 +13,7 @@
     "@biomejs/biome": "1.8.3",
     "concurrently": "^7.0.0"
   },
-  "workspaces": [
-    "api",
-    "website",
-    "og",
-    "packages/*"
-  ],
+  "workspaces": ["api", "website", "og", "packages/*"],
   "patchedDependencies": {
     "@remix-run/react@2.9.2": "patches/@remix-run%2Freact@2.9.2.patch"
   }
diff --git a/website/.gitignore b/website/.gitignore
index 7c028014..fd3dbb57 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -1,6 +1,36 @@
-node_modules
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 
-/.cache
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
 /build
-.env
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
 .vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/website/.npmrc b/website/.npmrc
deleted file mode 100644
index 165d6cf0..00000000
--- a/website/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-force=true
\ No newline at end of file
diff --git a/website/README.md b/website/README.md
index 6d3efb51..b1d47025 100644
--- a/website/README.md
+++ b/website/README.md
@@ -1,36 +1,5 @@
-# Welcome to Remix!
+# docs.page
 
-- 📖 [Remix docs](https://remix.run/docs)
+This is the website for the docs.page project, which hosts both the docs.page website and the documentation for projects.
 
-## Development
-
-Run the dev server:
-
-```shellscript
-npm run dev
-```
-
-## Deployment
-
-First, build your app for production:
-
-```sh
-npm run build
-```
-
-Then run the app in production mode:
-
-```sh
-npm start
-```
-
-Now you'll need to pick a host to deploy it to.
-
-### DIY
-
-If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
-
-Make sure to deploy the output of `npm run build`
-
-- `build/server`
-- `build/client`
+This project is a managed workspace using Bun - please see the contributing guide for more information and how to get started.
\ No newline at end of file
diff --git a/website/app/components/DocSearch.tsx b/website/app/components/DocSearch.tsx
deleted file mode 100644
index e6daa512..00000000
--- a/website/app/components/DocSearch.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import docsearch from "@docsearch/js";
-import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
-
-type Props = {
-  appId: string;
-  indexName: string;
-  apiKey: string;
-};
-
-export type DocSearchHandle = {
-  trigger(): void;
-};
-
-// This is a wrapper around the DocSearch library that allows us to trigger the search programmatically,
-// without rendering the styled input the library provides.
-const DocSearch = forwardRef<DocSearchHandle, Props>(
-  ({ appId, indexName, apiKey }, ref) => {
-    // A ref for the parent container where DocSearch will be mounted.
-    const container = useRef<HTMLDivElement>(null);
-    const button = useRef<HTMLButtonElement | null>(null);
-
-    // Enable the parent component to trigger the search programmatically.
-    useImperativeHandle(
-      ref,
-      () => {
-        return {
-          trigger() {
-            if (button.current) {
-              button.current.click();
-            }
-          },
-        };
-      },
-      [],
-    );
-
-    useEffect(() => {
-      if (!container.current) return;
-
-      // Apply the DocSearch logic to the hidden element.
-      docsearch({
-        container: container.current,
-        appId,
-        indexName,
-        apiKey,
-      });
-
-      // Get the direct child (button) of the container.
-      const mounted = container.current.firstElementChild;
-
-      // Store the button element in the ref.
-      if (mounted) {
-        button.current = mounted as HTMLButtonElement;
-      }
-    }, [apiKey, appId, indexName]);
-
-    // Hide the element so we can trigger the search programmatically.
-    return <div ref={container} className="hidden" />;
-  },
-);
-
-export default DocSearch;
diff --git a/website/app/entry.client.tsx b/website/app/entry.client.tsx
deleted file mode 100644
index 9cc7ae4e..00000000
--- a/website/app/entry.client.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { RemixBrowser } from "@remix-run/react";
-import { StrictMode, startTransition } from "react";
-import { hydrateRoot } from "react-dom/client";
-import type { DocsPageContext } from "~/context";
-
-declare global {
-  interface Window {
-    __docsPage?: DocsPageContext;
-  }
-}
-
-// A list of base domains which can run this app in production.
-const DOMAINS = ["docs.page", "staging.docs.page"];
-
-if (window.__docsPage) {
-  const hostname = window.location.hostname;
-
-  // Check if the current hostname is a vanity domain (e.g. `:org.docs.page`).
-  const isVanityDomain =
-    hostname.includes(".docs.page") && !DOMAINS.includes(hostname);
-
-  // It's a custom domain if it's not a vanity domain and it's not one of the base domains.
-  const isCustomDomain = !isVanityDomain && !DOMAINS.includes(hostname);
-
-  if (isVanityDomain || isCustomDomain) {
-    window.__remixContext.url = window.location.pathname;
-  }
-
-  // // const { owner, repository } = window.__docsPage;
-
-  // // A vanity domain is a rewrite request.
-  // if (isVanityDomain) {
-  //   // const basename = `/${owner}`;
-
-  //   // window.__remixContext.basename = basename;
-
-  //   // Remove the owner from the URL (since it's now part of the hostname).
-  //   window.__remixContext.url = window.location.pathname;
-
-  //   console.log("Rewriting context for vanity domain: ", {
-  //     basename: window.__remixContext.basename,
-  //     url: window.__remixContext.url,
-  //   });
-  // }
-  // // A custom domain is a proxy request.
-  // else if (isCustomDomain) {
-  //   // console.log("Custom domain detected: ", { hostname, owner, repository });
-  //   // const basename = `/${owner}/${repository}`;
-
-  //   // // Set the base name to the owner and repository (e.g. `/invertase/docs.page`).
-  //   // // window.__remixContext.basename = '';
-
-  //   // // // Replace the URL which includes the repository with the correct URL.
-  //   // // // For example: `/invertase/docs.page/configuration` -> `/configuration`.
-  //   // // window.__remixContext.url = '/invertase/docs.page/configuration';
-
-  //   // console.log("Rewriting context for custom domain: ", {
-  //   //   basename: window.__remixContext.basename,
-  //   //   url: window.__remixContext.url,
-  //   // });
-  // }
-}
-
-startTransition(() => {
-  hydrateRoot(
-    document,
-    <StrictMode>
-      <RemixBrowser />
-    </StrictMode>,
-  );
-});
diff --git a/website/app/layouts/DocsLayout.tsx b/website/app/layouts/DocsLayout.tsx
deleted file mode 100644
index 3b93242d..00000000
--- a/website/app/layouts/DocsLayout.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useState } from "react";
-import { Content } from "~/components/Content";
-import { Edit } from "~/components/Edit";
-import { Footer } from "~/components/Footer";
-import { Header } from "~/components/Header";
-import { PreviousNext } from "~/components/PreviousNext";
-import { Scripts } from "~/components/Scripts";
-import { Sidebar } from "~/components/Sidebar";
-import { TableOfContents } from "~/components/TableOfContents";
-import { Tabs } from "~/components/Tabs";
-import { ThemeScripts } from "~/components/Theme";
-import { useTabs } from "~/context";
-import { cn } from "~/utils";
-
-export function DocsLayout() {
-  const hasTabs = useTabs().length > 0;
-  const [sidebar, setSidebar] = useState(false);
-
-  function toggleSidebar() {
-    setSidebar((prev) => {
-      const open = !prev;
-
-      if (open) {
-        document.body.style.overflow = "hidden";
-      } else {
-        document.body.style.overflow = "";
-      }
-
-      return open;
-    });
-  }
-
-  return (
-    <>
-      <ThemeScripts />
-      <Scripts />
-      <section className="fixed z-10 inset-x-0 top-0 bg-background-dark/90 backdrop-blur">
-        <Header onMenuToggle={toggleSidebar} />
-        <Tabs onMenuToggle={toggleSidebar} />
-      </section>
-      <div className="max-w-8xl mx-auto px-5">
-        <section
-          className={cn(
-            "fixed z-10 w-[17rem] bottom-0 overflow-y-auto translate-x-[-19rem] lg:translate-x-0 transition-transform",
-            {
-              "top-16": !hasTabs && !sidebar,
-              "top-28": hasTabs && !sidebar,
-              "translate-x-0 top-0 z-20 bg-background border-r border-black/10 dark:border-white/10":
-                sidebar,
-            },
-          )}
-        >
-          <Sidebar onMenuToggle={toggleSidebar} />
-        </section>
-        <div
-          className={cn("relative lg:pl-[17rem]", {
-            "pt-16": !hasTabs,
-            "pt-28": hasTabs,
-          })}
-        >
-          <div
-            role="button"
-            className={cn(
-              "bg-background/50 z-10 absolute inset-0 lg:opacity-0 transition-opacity",
-              {
-                "pointer-events-none opacity-0": !sidebar,
-                "pointer-events-auto opacity-100": sidebar,
-              },
-            )}
-            onClick={() => toggleSidebar()}
-            onKeyDown={() => toggleSidebar()}
-          />
-          <section className="pt-8 ps-4 lg:ps-16 pe-4 flex">
-            <div className="min-w-0 flex-1 pr-0 xl:pr-12">
-              <Content />
-              <Edit />
-              <PreviousNext />
-              <div className="h-px bg-black/5 dark:bg-white/5 my-12" />
-              <Footer />
-            </div>
-            <div className="hidden xl:block relative lg:w-[17rem]">
-              <TableOfContents />
-            </div>
-          </section>
-        </div>
-      </div>
-    </>
-  );
-}
diff --git a/website/app/meta.ts b/website/app/meta.ts
deleted file mode 100644
index 4f3a92ed..00000000
--- a/website/app/meta.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import type { LinkDescriptor } from "@remix-run/node";
-import type { MetaDescriptor } from "@remix-run/react";
-
-export function getMetadata(): MetaDescriptor[] {
-  const title = "docs.page | Ship documentation, like you ship code";
-  const description =
-    "Publish beautiful online documentation instantly, from your code editor using markdown and a public GitHub repository.";
-  const image = "https://docs.page/social-preview.png";
-
-  return [
-    {
-      title,
-    },
-    {
-      name: "description",
-      content: description,
-    },
-    {
-      property: "og:title",
-      content: title,
-    },
-    {
-      property: "og:description",
-      content: description,
-    },
-    {
-      property: "og:image",
-      content: image,
-    },
-    {
-      name: "twitter:title",
-      content: title,
-    },
-    {
-      name: "twitter:description",
-      content: description,
-    },
-    {
-      name: "twitter:site",
-      content: "@invertaseio",
-    },
-    {
-      name: "twitter:image",
-      content: image,
-    },
-  ];
-}
-
-export function getLinkDescriptors(): LinkDescriptor[] {
-  return [
-    {
-      rel: "apple-touch-icon",
-      sizes: "180x180",
-      href: "/_docs.page/favicon/apple-touch-icon.png",
-    },
-    {
-      rel: "icon",
-      type: "image/png",
-      sizes: "32x32",
-      href: "/_docs.page/favicon/favicon-32x32.png",
-    },
-    {
-      rel: "icon",
-      type: "image/png",
-      sizes: "16x16",
-      href: "/_docs.page/favicon/favicon-16x16.png",
-    },
-    {
-      rel: "manifest",
-      href: "/_docs.page/favicon/site.webmanifest",
-    },
-    {
-      rel: "mask-icon",
-      href: "/_docs.page/favicon/safari-pinned-tab.svg",
-      color: "#5bbad5",
-    },
-  ];
-}
diff --git a/website/app/root.tsx b/website/app/root.tsx
deleted file mode 100644
index e67dec24..00000000
--- a/website/app/root.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import type { LinksFunction } from "@remix-run/node";
-import {
-  Links,
-  Meta,
-  Outlet,
-  Scripts,
-  ScrollRestoration,
-  isRouteErrorResponse,
-  useFetchers,
-  useLoaderData,
-  useNavigation,
-  useOutletContext,
-  useRouteError,
-  useRouteLoaderData,
-} from "@remix-run/react";
-
-import NProgress from "nprogress";
-import nProgressStyles from "nprogress/nprogress.css?url";
-import { type ReactElement, useEffect, useMemo } from "react";
-import zoomStyles from "react-medium-image-zoom/dist/styles.css?url";
-import { ErrorLayout } from "~/layouts/ErrorLayout";
-import type { BundleErrorResponse } from "./api";
-import styles from "./styles.css?url";
-import type { SharedEnvironmentVariables } from "./utils";
-
-NProgress.configure({ showSpinner: false });
-
-export const links: LinksFunction = () => [
-  { rel: "stylesheet", href: styles },
-  {
-    rel: "stylesheet",
-    href: zoomStyles,
-  },
-  {
-    rel: "stylesheet",
-    href: nProgressStyles,
-  },
-];
-
-export const loader = () => {
-  return {
-    ENV: {
-      VERCEL: process.env.VERCEL,
-      VERCEL_ENV: process.env.VERCEL_ENV,
-      VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA,
-    } satisfies SharedEnvironmentVariables,
-  };
-};
-
-export function Layout({ children }: { children: React.ReactNode }) {
-  const data = useRouteLoaderData<typeof loader>("root");
-
-  return (
-    <html lang="en">
-      <head>
-        <meta charSet="utf-8" />
-        <meta name="viewport" content="width=device-width, initial-scale=1" />
-        <meta name="theme-color" content="#0B0D0E" />
-        <link rel="preconnect" href="https://fonts.googleapis.com" />
-        <link
-          rel="preconnect"
-          href="https://fonts.gstatic.com"
-          crossOrigin="anonymous"
-        />
-        <link
-          href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=auto"
-          rel="stylesheet"
-        />
-        <link
-          href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap"
-          rel="stylesheet"
-        />
-        <link href="/_docs.page/fa/fontawesome.min.css" rel="stylesheet" />
-        <link href="/_docs.page/fa/brands.min.css" rel="stylesheet" />
-        <link href="/_docs.page/fa/solid.min.css" rel="stylesheet" />
-        <Meta />
-        <Links />
-      </head>
-      <body
-        className="antialiased bg-background text-gray-900 dark:text-gray-200"
-        style={{
-          textRendering: "optimizeLegibility",
-        }}
-      >
-        <script
-          dangerouslySetInnerHTML={{
-            __html: `window.ENV = ${
-              data?.ENV ? JSON.stringify(data.ENV) : "{}"
-            }`,
-          }}
-        />
-        {children}
-        <ScrollRestoration />
-        <Scripts />
-      </body>
-    </html>
-  );
-}
-
-export default function App() {
-  const navigation = useNavigation();
-  const fetchers = useFetchers();
-
-  /**
-   * This gets the state of every fetcher active on the app and combine it with
-   * the state of the global transition (Link and Form), then use them to
-   * determine if the app is idle or if it's loading.
-   * Here we consider both loading and submitting as loading.
-   * https://sergiodxa.com/tutorials/use-nprogress-in-a-remix-app
-   */
-  const state = useMemo<"idle" | "loading">(
-    function getGlobalState() {
-      const states = [
-        navigation.state,
-        ...fetchers.map((fetcher) => fetcher.state),
-      ];
-      if (states.every((state) => state === "idle")) return "idle";
-      return "loading";
-    },
-    [navigation.state, fetchers],
-  );
-
-  useEffect(() => {
-    // and when it's something else it means it's either submitting a form or
-    // waiting for the loaders of the next location so we start it
-    if (state === "loading") NProgress.start();
-    // when the state is idle then we can to complete the progress bar
-    if (state === "idle") NProgress.done();
-  }, [state]);
-
-  return <Outlet />;
-}
-
-export function ErrorBoundary() {
-  const error = useRouteError();
-
-  let title = "Something went wrong";
-  let description = "An unexpected error occurred.";
-
-  const body = (children: ReactElement) => (
-    <>
-      <script
-        dangerouslySetInnerHTML={{
-          __html: `
-              if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
-                document.documentElement.setAttribute('data-theme', 'dark');
-              }`,
-        }}
-      />
-      {children}
-    </>
-  );
-
-  if (isRouteErrorResponse(error)) {
-    const isBundleError = error.data !== null && typeof error.data === "object";
-
-    if (isBundleError) {
-      return body(<ErrorLayout error={error.data as BundleErrorResponse} />);
-    }
-
-    if (error.status === 404) {
-      title = "Page not found";
-      description = "The page you were looking for could not be found.";
-    }
-  }
-
-  return body(<ErrorLayout title={title} description={description} />);
-}
diff --git a/website/app/routes/$/route.tsx b/website/app/routes/$/route.tsx
deleted file mode 100644
index 82fc4bd1..00000000
--- a/website/app/routes/$/route.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-import {
-  type MetaDescriptor,
-  type MetaFunction,
-  useLoaderData,
-} from "@remix-run/react";
-import type { HeadersFunction, LoaderFunctionArgs } from "@vercel/remix";
-import { redirect } from "@vercel/remix";
-import { getBundle } from "~/api";
-import { Scripts } from "~/components/Scripts";
-import { type Context, PageContext } from "~/context";
-import { DocsLayout } from "~/layouts/DocsLayout";
-
-import docsearch from "@docsearch/css/dist/style.css?url";
-import { trackPageRequest } from "~/plausible";
-import {
-  ensureLeadingSlash,
-  getAssetSrc,
-  getEnvironment,
-  getRequestParams,
-} from "~/utils";
-import domains from "../../../../domains.json";
-
-export const loader = async (args: LoaderFunctionArgs) => {
-  const requestUrl = new URL(args.request.url);
-  const { owner, repository, ref, path, vanity } = getRequestParams(args);
-  const environment = getEnvironment();
-
-  const bundle = await getBundle({
-    owner,
-    repository,
-    path,
-    ref,
-  }).catch((response) => {
-    throw response;
-  });
-
-  // Check whether the repository has a domain assigned.
-  const domain = domains
-    .find(([, repo]) => repo === `${owner}/${repository}`)
-    ?.at(0);
-
-  // Check if the user has set a redirect in the frontmatter of this page.
-  const redirectTo =
-    typeof bundle.frontmatter.redirect === "string"
-      ? bundle.frontmatter.redirect
-      : undefined;
-
-  // Redirect to the specified URL.
-  if (redirectTo && redirectTo.length > 0) {
-    if (redirectTo.startsWith("http://") || redirectTo.startsWith("https://")) {
-      throw redirect(redirectTo);
-    }
-
-    let url = "";
-    if (vanity) {
-      url = `https://${owner}.docs.page/${repository}`;
-      if (ref) url += `~${ref}`;
-      url += redirectTo;
-    } else if (domain && environment === "production") {
-      // If there is a domain setup, always redirect to it.
-      url = `https://${domain}`;
-      if (ref) url += `/~${ref}`;
-      url += redirectTo;
-    } else {
-      // If no domain, redirect to docs.page.
-      url = `${requestUrl.origin}/${owner}/${repository}`;
-      if (ref) url += `~${ref}`;
-      url += redirectTo;
-    }
-
-    console.log(
-      "Handling redirect",
-      redirectTo,
-      { owner, repository, ref, vanity, domain, environment },
-      url
-    );
-
-    throw redirect(url);
-  }
-
-  if (import.meta.env.PROD) {
-    // Track the page request.
-    await trackPageRequest(args.request, owner, repository);
-  }
-
-  return {
-    path: ensureLeadingSlash(path),
-    owner,
-    repository,
-    ref,
-    domain: domain && environment === "production" ? domain : undefined,
-    vanity,
-    bundle,
-    preview: false,
-  } satisfies Context;
-};
-
-export default function DocsPage() {
-  const ctx = useLoaderData<typeof loader>();
-
-  return (
-    <PageContext.Provider value={ctx}>
-      <script
-        dangerouslySetInnerHTML={{
-          __html: `window.__docsPage = ${JSON.stringify(ctx)}`,
-        }}
-      />
-      <Scripts />
-      <DocsLayout />
-    </PageContext.Provider>
-  );
-}
-
-export const headers: HeadersFunction = () => ({
-  "Cache-Control": "s-maxage=1, stale-while-revalidate=59",
-});
-
-export const meta: MetaFunction<typeof loader> = ({ data: ctx }) => {
-  const descriptors: MetaDescriptor[] = [];
-
-  if (!ctx) {
-    return descriptors;
-  }
-
-  if (ctx.bundle.config.favicon?.light) {
-    descriptors.push({
-      tagName: "link",
-      rel: "icon",
-      media: ctx.bundle.config.favicon?.dark
-        ? // If there is a dark favicon, add a media query to prefer light mode only.
-          "(prefers-color-scheme: light)"
-        : undefined,
-      href: getAssetSrc(ctx, ctx.bundle.config.favicon.light),
-    });
-  }
-
-  if (ctx.bundle.config.favicon?.dark) {
-    descriptors.push({
-      tagName: "link",
-      rel: "icon",
-      media: ctx.bundle.config.favicon?.light
-        ? // If there is a light favicon, add a media query to prefer dark mode only.
-          "(prefers-color-scheme: dark)"
-        : undefined,
-      href: getAssetSrc(ctx, ctx.bundle.config.favicon.dark),
-    });
-  }
-
-  // Add noindex meta tag if the frontmatter or config has noindex set to true.
-  if (
-    ctx.bundle.frontmatter.noindex === true ||
-    ctx.bundle.config.seo?.noindex === true
-  ) {
-    descriptors.push({
-      name: "robots",
-      content: "noindex",
-    });
-  }
-
-  const title =
-    ctx.bundle.frontmatter.title || ctx.bundle.config.name || "docs.page";
-  const description =
-    ctx.bundle.frontmatter.description || ctx.bundle.config.description;
-
-  let image = ctx.bundle.frontmatter.image
-    ? String(ctx.bundle.frontmatter.image)
-    : ctx.bundle.config.socialPreview;
-
-  // If there is no image, generate one.
-  if (image === undefined) {
-    const params = JSON.stringify({
-      owner: ctx.owner,
-      repository: ctx.repository,
-      title,
-      description,
-      // Use the light logo, and fallback to the dark logo
-      logo:
-        ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark
-          ? getAssetSrc(
-              ctx,
-              ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark || ""
-            )
-          : undefined,
-    });
-
-    const base64String =
-      typeof window !== "undefined"
-        ? window.btoa(params)
-        : Buffer.from(params).toString("base64");
-
-    image = `https://og.docs.page?params=${base64String}`;
-  }
-  // If it has been set to false, disable the image.
-  else if (image === false) {
-    image = undefined;
-  }
-  // Otherwise the image is a path, so we need to resolve it.
-  else {
-    image = getAssetSrc(ctx, image);
-  }
-
-  descriptors.push({
-    title,
-  });
-
-  descriptors.push({
-    property: "og:title",
-    content: title,
-  });
-
-  descriptors.push({
-    name: "twitter:title",
-    content: title,
-  });
-
-  if (image) {
-    descriptors.push({
-      name: "twitter:card",
-      content: "summary_large_image",
-    });
-
-    descriptors.push({
-      name: "twitter:image",
-      content: image,
-    });
-
-    descriptors.push({
-      property: "og:image",
-      content: image,
-    });
-  }
-
-  if (description) {
-    descriptors.push({
-      name: "description",
-      content: description,
-    });
-    descriptors.push({
-      property: "og:description",
-      content: description,
-    });
-    descriptors.push({
-      name: "twitter:description",
-      content: description,
-    });
-  }
-
-  if ("domain" in ctx && ctx.domain) {
-    descriptors.push({
-      property: "og:url",
-      content: `https://${ctx.domain}`,
-    });
-  }
-
-  if (ctx.bundle.config.social?.x) {
-    descriptors.push({
-      name: "twitter:site",
-      content: `@${ctx.bundle.config.social.x}`,
-    });
-  }
-
-  if (ctx.bundle.config.search?.docsearch) {
-    // https://docsearch.algolia.com/docs/DocSearch-v3#preconnect
-    descriptors.push({
-      tagName: "link",
-      rel: "preconnect",
-      crossOrigin: "true",
-      href: `https://${ctx.bundle.config.search?.docsearch.appId}-dsn.algolia.net`,
-    });
-
-    descriptors.push({
-      tagName: "link",
-      rel: "stylesheet",
-      href: docsearch,
-    });
-  }
-
-  return descriptors;
-};
diff --git a/website/app/routes/_layout.tsx b/website/app/routes/_layout.tsx
deleted file mode 100644
index 7cd380cb..00000000
--- a/website/app/routes/_layout.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Outlet } from "@remix-run/react";
-import { useInlineScript } from "~/hooks";
-
-export default function Layout() {
-  const scripts = useInlineScript(`<script>(() => {
-		document.documentElement.setAttribute('data-theme', 'dark');
-    const root = document.documentElement;
-		root.style.setProperty('--background-dark', '224, 71%, 4%');		
-	})()</script>`);
-
-  return (
-    <>
-      {scripts}
-      <script
-        defer
-        data-domain="docs.page"
-        src="https://plausible.io/js/script.js"
-      />
-      <Outlet />
-    </>
-  );
-}
diff --git a/website/app/routes/schema[.]json.ts b/website/app/routes/schema[.]json.ts
deleted file mode 100644
index f16d8194..00000000
--- a/website/app/routes/schema[.]json.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const loader = async () => {
-  const response = await fetch("https://staging-api.docs.page/schema.json");
-  return response.json();
-};
diff --git a/website/app/routes/_layout.preview.$/fsa.d.ts b/website/fsa.d.ts
similarity index 100%
rename from website/app/routes/_layout.preview.$/fsa.d.ts
rename to website/fsa.d.ts
diff --git a/website/next.config.mjs b/website/next.config.mjs
new file mode 100644
index 00000000..7fd5de2a
--- /dev/null
+++ b/website/next.config.mjs
@@ -0,0 +1,38 @@
+import path from "node:path";
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  reactStrictMode: true,
+  typescript: {
+    // !! WARN !!
+    // Dangerously allow production builds to successfully complete even if
+    // your project has type errors.
+    // !! WARN !!
+    ignoreBuildErrors: true,
+  },
+  webpack: (config) => {
+    // Allow transpiling TypeScript from an external package
+    config.module.rules.push({
+      test: /\.ts$/,
+      include: [path.resolve(process.cwd(), "../packages/cli")],
+      use: {
+        loader: "ts-loader",
+        options: {
+          transpileOnly: true, // For faster compilation, but optional
+        },
+      },
+    });
+
+    return config;
+  },
+  rewrites() {
+    return [
+      {
+        source: "/schema.json",
+        destination: "https://staging-api.docs.page/schema.json",
+      },
+    ];
+  },
+};
+
+export default nextConfig;
diff --git a/website/package.json b/website/package.json
index b56bd046..eaaefdcc 100644
--- a/website/package.json
+++ b/website/package.json
@@ -1,57 +1,45 @@
 {
-  "name": "website-remix",
+  "name": "website-next",
+  "version": "0.1.0",
   "private": true,
-  "sideEffects": false,
-  "type": "module",
   "scripts": {
-    "build": "remix vite:build",
-    "dev": "remix vite:dev --host",
-    "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
-    "start": "remix-serve ./build/server/index.js",
-    "typecheck": "tsc"
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start",
+    "lint": "next lint"
   },
   "dependencies": {
     "@docs.page/cli": "workspace:*",
-    "@docsearch/js": "^3.6.0",
-    "@headlessui/react": "^2.0.4",
+    "@docsearch/js": "^3.6.1",
+    "@headlessui/react": "^2.1.8",
     "@mdx-js/mdx": "^3.0.1",
-    "@remix-run/node": "2.9.2",
-    "@remix-run/react": "2.9.2",
-    "@remix-run/serve": "2.9.2",
-    "@tanstack/react-query": "^5.45.0",
-    "@types/wicg-file-system-access": "^2023.10.5",
-    "@vercel/remix": "2.9.2",
+    "@tanstack/react-query": "^5.56.2",
     "classnames": "^2.5.1",
     "color": "^4.2.3",
     "idb": "^8.0.0",
-    "isbot": "^4.1.0",
-    "lucide-react": "^0.394.0",
-    "mime": "^4.0.3",
+    "lucide-react": "^0.445.0",
+    "mime-db": "^1.53.0",
+    "mime-types": "^2.1.35",
+    "next": "14.2.13",
     "nprogress": "^0.2.0",
-    "react": "18.3.0-canary-bb0944fe5-20240313",
-    "react-dom": "18.3.0-canary-bb0944fe5-20240313",
-    "react-medium-image-zoom": "^5.2.4",
+    "react": "18.3.1",
+    "react-dom": "18.3.1",
+    "react-medium-image-zoom": "^5.2.10",
     "request-ip": "^3.3.0",
-    "tailwind-merge": "^2.3.0"
+    "tailwind-merge": "^2.5.2"
   },
   "devDependencies": {
-    "@remix-run/dev": "^2.9.2",
-    "@tailwindcss/typography": "^0.5.13",
+    "@tailwindcss/typography": "^0.5.15",
     "@types/color": "^3.0.6",
+    "@types/mime-types": "^2.1.4",
+    "@types/node": "^20",
     "@types/nprogress": "^0.2.3",
-    "@types/react": "^18.3.3",
-    "@types/react-dom": "^18.3.0",
+    "@types/react": "18.3.8",
+    "@types/react-dom": "18.3.0",
     "@types/request-ip": "^0.0.41",
-    "@typescript-eslint/eslint-plugin": "^6.7.4",
-    "@typescript-eslint/parser": "^6.7.4",
-    "autoprefixer": "^10.4.19",
-    "postcss": "^8.4.38",
-    "tailwindcss": "^3.4.4",
-    "typescript": "5.5.2",
-    "vite": "^5.1.0",
-    "vite-tsconfig-paths": "^4.2.1"
-  },
-  "engines": {
-    "node": ">=20.0.0"
+    "postcss": "^8",
+    "tailwindcss": "^3.4.1",
+    "ts-loader": "^9.5.1",
+    "typescript": "^5"
   }
 }
diff --git a/website/postcss.config.js b/website/postcss.config.js
deleted file mode 100644
index 2aa7205d..00000000
--- a/website/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
-  plugins: {
-    tailwindcss: {},
-    autoprefixer: {},
-  },
-};
diff --git a/website/postcss.config.mjs b/website/postcss.config.mjs
new file mode 100644
index 00000000..1a69fd2a
--- /dev/null
+++ b/website/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    tailwindcss: {},
+  },
+};
+
+export default config;
diff --git a/website/public/assets/beautiful-by-design.png b/website/public/_docs.page/assets/beautiful-by-design.png
similarity index 100%
rename from website/public/assets/beautiful-by-design.png
rename to website/public/_docs.page/assets/beautiful-by-design.png
diff --git a/website/public/assets/collaborate-with-live-preview.png b/website/public/_docs.page/assets/collaborate-with-live-preview.png
similarity index 100%
rename from website/public/assets/collaborate-with-live-preview.png
rename to website/public/_docs.page/assets/collaborate-with-live-preview.png
diff --git a/website/public/assets/customise-and-theme.png b/website/public/_docs.page/assets/customise-and-theme.png
similarity index 100%
rename from website/public/assets/customise-and-theme.png
rename to website/public/_docs.page/assets/customise-and-theme.png
diff --git a/website/public/assets/get-started/add-content-editor.png b/website/public/_docs.page/assets/get-started/add-content-editor.png
similarity index 100%
rename from website/public/assets/get-started/add-content-editor.png
rename to website/public/_docs.page/assets/get-started/add-content-editor.png
diff --git a/website/public/assets/get-started/preview.png b/website/public/_docs.page/assets/get-started/preview.png
similarity index 100%
rename from website/public/assets/get-started/preview.png
rename to website/public/_docs.page/assets/get-started/preview.png
diff --git a/website/public/assets/get-started/publish.png b/website/public/_docs.page/assets/get-started/publish.png
similarity index 100%
rename from website/public/assets/get-started/publish.png
rename to website/public/_docs.page/assets/get-started/publish.png
diff --git a/website/public/assets/get-started/terminal.png b/website/public/_docs.page/assets/get-started/terminal.png
similarity index 100%
rename from website/public/assets/get-started/terminal.png
rename to website/public/_docs.page/assets/get-started/terminal.png
diff --git a/website/public/assets/manage-docs-as-code.png b/website/public/_docs.page/assets/manage-docs-as-code.png
similarity index 100%
rename from website/public/assets/manage-docs-as-code.png
rename to website/public/_docs.page/assets/manage-docs-as-code.png
diff --git a/website/public/assets/publish-instantly.png b/website/public/_docs.page/assets/publish-instantly.png
similarity index 100%
rename from website/public/assets/publish-instantly.png
rename to website/public/_docs.page/assets/publish-instantly.png
diff --git a/website/public/docs-page-hero-video.webm b/website/public/_docs.page/docs-page-hero-video.webm
similarity index 100%
rename from website/public/docs-page-hero-video.webm
rename to website/public/_docs.page/docs-page-hero-video.webm
diff --git a/website/public/logo.png b/website/public/_docs.page/logo.png
similarity index 100%
rename from website/public/logo.png
rename to website/public/_docs.page/logo.png
diff --git a/website/public/_docs.page/logo.webp b/website/public/_docs.page/logo.webp
deleted file mode 100644
index a74c7a86..00000000
Binary files a/website/public/_docs.page/logo.webp and /dev/null differ
diff --git a/website/public/social-preview.png b/website/public/_docs.page/social-preview.png
similarity index 100%
rename from website/public/social-preview.png
rename to website/public/_docs.page/social-preview.png
diff --git a/website/public/logo.svg b/website/public/logo.svg
deleted file mode 100644
index 469073a4..00000000
--- a/website/public/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="459" height="533" fill="none"><g filter="url(#filter0_d_262_362)"><path fill="url(#paint0_linear_262_362)" d="M200.693 529H4.813V19.91h193.892c52.367 0 97.608 10.19 135.724 30.574 38.281 20.218 67.779 49.385 88.494 87.5 20.881 37.95 31.321 83.44 31.321 136.471 0 53.03-10.357 98.603-31.072 136.718-20.715 37.95-50.047 67.117-87.997 87.5C297.225 518.891 252.398 529 200.693 529Zm-57.67-117.33h52.699c25.189 0 46.65-4.06 64.382-12.18 17.897-8.12 31.486-22.124 40.767-42.01 9.446-19.886 14.169-47.561 14.169-83.025 0-35.464-4.806-63.14-14.418-83.026-9.446-19.886-23.366-33.89-41.761-42.01-18.229-8.12-40.602-12.18-67.117-12.18h-48.721V411.67Z"/><path fill="#F3F3F3" d="M123 4H25v163l49-49 49 49V4Z"/></g><defs><linearGradient id="paint0_linear_262_362" x1="148.37" x2="148.378" y1="-5989.75" y2="9880.56" gradientUnits="userSpaceOnUse"><stop stop-color="#ECC918"/><stop offset="0.5" stop-color="#ECB118"/></linearGradient><filter id="filter0_d_262_362" width="457.432" height="533" x="0.8125" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_262_362"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_262_362" result="shape"/></filter></defs></svg>
\ No newline at end of file
diff --git a/website/app/api.ts b/website/src/api.ts
similarity index 85%
rename from website/app/api.ts
rename to website/src/api.ts
index 1a18cde2..3a7454e6 100644
--- a/website/app/api.ts
+++ b/website/src/api.ts
@@ -4,9 +4,8 @@ import type {
   BundlerOutput,
   SidebarGroup,
 } from "../../api/src/types";
-
 import { COMPONENTS } from "./components/Content";
-import { getBuildHash } from "./utils";
+import { getBuildHash } from "./env";
 
 export type {
   BundleResponse,
@@ -22,12 +21,7 @@ type GetBundleArgs = {
   path?: string;
 };
 
-const PRODUCTION = process.env.NODE_ENV === "production";
-
-// const API_URL =
-//   process.env.API_URL ||
-//   (PRODUCTION ? "https://api.docs.page" : "http://localhost:8080");
-const API_URL = "https://staging-api.docs.page";
+const API_URL = process.env.API_URL || "http://localhost:8080";
 
 export async function getBundle(args: GetBundleArgs): Promise<BundlerOutput> {
   const params = new URLSearchParams({
@@ -68,11 +62,10 @@ type GetPreviewBundleArgs = {
 };
 
 export async function getPreviewBundle(
-  args: GetPreviewBundleArgs,
+  args: GetPreviewBundleArgs
 ): Promise<BundlerOutput> {
   const response = await fetch(`${API_URL}/preview`, {
     method: "POST",
-    duplex: "half",
     headers: new Headers({
       "docs-page-preview": "true", // Disables caching on preview requests
     }),
diff --git a/website/app/components/Anchors.tsx b/website/src/components/Anchors.tsx
similarity index 100%
rename from website/app/components/Anchors.tsx
rename to website/src/components/Anchors.tsx
diff --git a/website/app/components/Button.tsx b/website/src/components/Button.tsx
similarity index 79%
rename from website/app/components/Button.tsx
rename to website/src/components/Button.tsx
index ee507f1f..501b6a0f 100644
--- a/website/app/components/Button.tsx
+++ b/website/src/components/Button.tsx
@@ -1,16 +1,17 @@
 import { ChevronRightIcon } from "lucide-react";
-import { type ComponentProps, cloneElement, createElement } from "react";
+import Link from "next/link";
+import { type ComponentProps, cloneElement } from "react";
 import { cn } from "~/utils";
 
 type Props =
   | ({
-      as: "a";
+      element: "a";
       href: string;
       children: string;
       cta?: boolean;
     } & ComponentProps<"a">)
   | ({
-      as: "button";
+      element: "button";
       children: string;
       cta?: boolean;
     } & ComponentProps<"button">);
@@ -18,10 +19,10 @@ type Props =
 export function Button({ className, cta, ...props }: Props) {
   let el: React.ReactNode;
 
-  if (props.as === "button") {
+  if (props.element === "button") {
     el = <button {...props} />;
   } else {
-    el = <a {...props} href={props.href} />;
+    el = <Link {...props} href={props.href} />;
   }
 
   const child = (
@@ -36,6 +37,7 @@ export function Button({ className, cta, ...props }: Props) {
       )}
     >
       <span>{props.children}</span>
+      {/* @ts-expect-error: Weird issue with icons */}
       <ChevronRightIcon size={18} />
     </div>
   );
diff --git a/website/app/components/Content.tsx b/website/src/components/Content.tsx
similarity index 100%
rename from website/app/components/Content.tsx
rename to website/src/components/Content.tsx
diff --git a/website/src/components/DocSearch.tsx b/website/src/components/DocSearch.tsx
new file mode 100644
index 00000000..cf1fe47b
--- /dev/null
+++ b/website/src/components/DocSearch.tsx
@@ -0,0 +1,58 @@
+import docsearch from "@docsearch/js";
+import Head from "next/head";
+import { useEffect, useImperativeHandle, useRef } from "react";
+import "@docsearch/css/dist/style.css";
+
+export type DocSearchProps = {
+  appId: string;
+  indexName: string;
+  apiKey: string;
+  forwardedRef?: React.ForwardedRef<DocSearchHandle>;
+};
+
+export type DocSearchHandle = {
+  trigger(): void;
+};
+
+export default function DocSearch(props: DocSearchProps) {
+  const { appId, indexName, apiKey, forwardedRef } = props;
+  const container = useRef<HTMLDivElement>(null);
+  const button = useRef<HTMLButtonElement | null>(null);
+
+  useImperativeHandle(forwardedRef, () => ({
+    trigger() {
+      if (button.current) {
+        button.current.click();
+      }
+    },
+  }));
+
+  useEffect(() => {
+    if (!container.current) return;
+
+    docsearch({
+      container: container.current,
+      appId,
+      indexName,
+      apiKey,
+    });
+
+    const mounted = container.current.firstElementChild;
+    if (mounted) {
+      button.current = mounted as HTMLButtonElement;
+    }
+  }, [apiKey, appId, indexName]);
+
+  return (
+    <>
+      <Head>
+        <link
+          rel="preconnect"
+          crossOrigin="anonymous"
+          href={`https://${appId}-dsn.algolia.net`}
+        />
+      </Head>
+      <div ref={container} className="hidden" />
+    </>
+  );
+}
diff --git a/website/app/components/Edit.tsx b/website/src/components/Edit.tsx
similarity index 86%
rename from website/app/components/Edit.tsx
rename to website/src/components/Edit.tsx
index 41cae486..6452dc6f 100644
--- a/website/app/components/Edit.tsx
+++ b/website/src/components/Edit.tsx
@@ -1,4 +1,4 @@
-import { PencilIcon } from "lucide-react";
+import { PencilIcon, StarIcon } from "lucide-react";
 import { usePageContext, useSourceUrl } from "~/context";
 
 export function Edit() {
@@ -15,6 +15,7 @@ export function Edit() {
         href={url}
         className="group border border-black/20 dark:border-white/20 transition-all hover:border-black/70 hover:dark:border-white/70 rounded-md px-2.5 py-1.5 inline-flex items-center gap-2"
       >
+        {/* @ts-expect-error: Weird issue with icons */}
         <PencilIcon
           size={16}
           className="opacity-50 transition-all group-hover:opacity-75"
diff --git a/website/app/components/Footer.tsx b/website/src/components/Footer.tsx
similarity index 100%
rename from website/app/components/Footer.tsx
rename to website/src/components/Footer.tsx
diff --git a/website/app/components/GitHubCard.tsx b/website/src/components/GitHubCard.tsx
similarity index 95%
rename from website/app/components/GitHubCard.tsx
rename to website/src/components/GitHubCard.tsx
index a6afe70d..33d08b26 100644
--- a/website/app/components/GitHubCard.tsx
+++ b/website/src/components/GitHubCard.tsx
@@ -39,12 +39,14 @@ export function GitHubCard() {
         </div>
         <div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity duration-200 ease-in-out">
           <div className="flex items-center gap-1">
+            {/* @ts-expect-error: Weird issue with icons */}
             <StarIcon size={12} />
             <code className="text-[10px]">
               {ctx.bundle.stars.toLocaleString()}
             </code>
           </div>
           <div className="flex items-center gap-1">
+            {/* @ts-expect-error: Weird issue with icons */}
             <GitForkIcon size={12} />
             <code className="text-[10px]">
               {ctx.bundle.forks.toLocaleString()}
diff --git a/website/app/components/Header.tsx b/website/src/components/Header.tsx
similarity index 100%
rename from website/app/components/Header.tsx
rename to website/src/components/Header.tsx
diff --git a/website/app/layouts/HeroGradient.tsx b/website/src/components/HeroGradient.tsx
similarity index 100%
rename from website/app/layouts/HeroGradient.tsx
rename to website/src/components/HeroGradient.tsx
diff --git a/website/app/components/Icon.tsx b/website/src/components/Icon.tsx
similarity index 100%
rename from website/app/components/Icon.tsx
rename to website/src/components/Icon.tsx
diff --git a/website/app/components/Link.tsx b/website/src/components/Link.tsx
similarity index 78%
rename from website/app/components/Link.tsx
rename to website/src/components/Link.tsx
index e4bddb5b..76d78f2b 100644
--- a/website/app/components/Link.tsx
+++ b/website/src/components/Link.tsx
@@ -1,4 +1,4 @@
-import { Link as InternalLink } from "@remix-run/react";
+import InternalLink from "next/link";
 import type { ComponentProps } from "react";
 import { useHref } from "~/context";
 import { isExternalLink } from "~/utils";
@@ -14,5 +14,5 @@ export function Link(props: LinkProps) {
     );
   }
 
-  return <InternalLink {...props} to={href} />;
+  return <InternalLink {...props} href={"/getting-started"} />;
 }
diff --git a/website/app/components/Locale.tsx b/website/src/components/Locale.tsx
similarity index 98%
rename from website/app/components/Locale.tsx
rename to website/src/components/Locale.tsx
index 4378d4dc..72633a8d 100644
--- a/website/app/components/Locale.tsx
+++ b/website/src/components/Locale.tsx
@@ -26,6 +26,7 @@ export function Locale() {
       <Menu>
         <MenuButton className="flex items-center gap-1.5 bg-gray-200/60 hover:bg-gray-200/80 dark:bg-white/10 hover:dark:bg-white/20 transition-all rounded-full pl-4 pr-3 py-1.5 text-xs font-bold">
           <span>{iso639LanguageCodes[locale]}</span>
+          {/* @ts-expect-error: Weird issue with icons */}
           <ChevronDownIcon size={12} />
         </MenuButton>
         <Transition
diff --git a/website/app/components/Logo.tsx b/website/src/components/Logo.tsx
similarity index 100%
rename from website/app/components/Logo.tsx
rename to website/src/components/Logo.tsx
diff --git a/website/app/components/MenuToggle.tsx b/website/src/components/MenuToggle.tsx
similarity index 100%
rename from website/app/components/MenuToggle.tsx
rename to website/src/components/MenuToggle.tsx
diff --git a/website/src/components/NProgress.tsx b/website/src/components/NProgress.tsx
new file mode 100644
index 00000000..ec73c577
--- /dev/null
+++ b/website/src/components/NProgress.tsx
@@ -0,0 +1,34 @@
+import Router from "next/router";
+import NProgress from "nprogress";
+
+let timer: Timer;
+let state: string;
+const delay = 250;
+
+NProgress.configure({ showSpinner: false });
+
+function load() {
+  if (state === "loading") {
+    return;
+  }
+
+  state = "loading";
+
+  timer = setTimeout(() => {
+    NProgress.start();
+  }, delay); // only show progress bar if it takes longer than the delay
+}
+
+function stop() {
+  state = "stop";
+  clearTimeout(timer);
+  NProgress.done();
+}
+
+Router.events.on("routeChangeStart", load);
+Router.events.on("routeChangeComplete", stop);
+Router.events.on("routeChangeError", stop);
+
+export default function () {
+  return null;
+}
diff --git a/website/app/components/PreviousNext.tsx b/website/src/components/PreviousNext.tsx
similarity index 100%
rename from website/app/components/PreviousNext.tsx
rename to website/src/components/PreviousNext.tsx
diff --git a/website/app/components/RefBadge.tsx b/website/src/components/RefBadge.tsx
similarity index 100%
rename from website/app/components/RefBadge.tsx
rename to website/src/components/RefBadge.tsx
diff --git a/website/app/components/Scripts.tsx b/website/src/components/Scripts.tsx
similarity index 90%
rename from website/app/components/Scripts.tsx
rename to website/src/components/Scripts.tsx
index a8e62555..a979d753 100644
--- a/website/app/components/Scripts.tsx
+++ b/website/src/components/Scripts.tsx
@@ -1,5 +1,6 @@
+import Script from "next/script";
 import { usePageContext } from "~/context";
-import { getEnvironment } from "~/utils";
+import { getEnvironment } from "~/env";
 
 export function Scripts() {
   const ctx = usePageContext();
@@ -13,13 +14,13 @@ export function Scripts() {
   return (
     <>
       {!!scripts?.googleTagManager && (
-        <script
+        <Script
           async
           src={`https://www.googletagmanager.com/gtag/js?id=${scripts.googleTagManager}`}
         />
       )}
       {!!scripts?.googleAnalytics && (
-        <script
+        <Script
           async
           dangerouslySetInnerHTML={{
             __html: `
@@ -30,7 +31,7 @@ export function Scripts() {
         />
       )}
       {"domain" in ctx && !!ctx.domain && !!scripts?.plausible && (
-        <script
+        <Script
           async
           defer
           data-domain={ctx.domain}
diff --git a/website/app/components/Search.tsx b/website/src/components/Search.tsx
similarity index 76%
rename from website/app/components/Search.tsx
rename to website/src/components/Search.tsx
index 7f31a91e..e13e6729 100644
--- a/website/app/components/Search.tsx
+++ b/website/src/components/Search.tsx
@@ -1,12 +1,20 @@
 import { SearchIcon } from "lucide-react";
-import { lazy, useRef } from "react";
+import dynamic from "next/dynamic";
+import { forwardRef, useRef } from "react";
 import { usePageContext } from "~/context";
 
-import type { DocSearchHandle } from "./DocSearch";
-const DocSearch = lazy(() => import("./DocSearch"));
+import type { DocSearchHandle, DocSearchProps } from "./DocSearch";
+
+const DocSearchDynamic = dynamic(() => import("./DocSearch"), {
+  ssr: false,
+  loading: () => null,
+});
+
+const DocSearch = forwardRef<DocSearchHandle, DocSearchProps>((props, ref) => {
+  return <DocSearchDynamic {...props} forwardedRef={ref} />;
+});
 
 type Props = {
-  // If children are provided, will render as the child.
   children?: (toggle: () => void) => React.ReactNode;
 };
 
@@ -19,6 +27,7 @@ export function Search(props: Props) {
   const hasSearch = !!docsearch;
 
   function handleSearchEvent() {
+    console.log("handleSearchEvent", docsearchRef.current);
     if (docsearchRef.current) {
       docsearchRef.current.trigger();
     }
diff --git a/website/app/components/Sidebar.tsx b/website/src/components/Sidebar.tsx
similarity index 94%
rename from website/app/components/Sidebar.tsx
rename to website/src/components/Sidebar.tsx
index 409f9042..724826df 100644
--- a/website/app/components/Sidebar.tsx
+++ b/website/src/components/Sidebar.tsx
@@ -1,5 +1,6 @@
-import { NavLink, useLocation } from "@remix-run/react";
 import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/router";
 import { type ReactElement, cloneElement, useState } from "react";
 import { useHref, usePageContext, useSidebar } from "~/context";
 import { cn, getHref } from "~/utils";
@@ -77,7 +78,7 @@ function SidebarLinks(
 
 // Renders a group of sidebar links, either as a link or a group of links.
 function SidebarGroup(props: { group: Pages[number] } & { depth: number }) {
-  const location = useLocation();
+  const location = useRouter();
   const ctx = usePageContext();
 
   // A recursive function to determine if this group
@@ -90,7 +91,7 @@ function SidebarGroup(props: { group: Pages[number] } & { depth: number }) {
         }
       } else if (page.href) {
         const href = getHref(ctx, page.href);
-        if (location.pathname === href) {
+        if (location.asPath === href) {
           return true;
         }
       }
@@ -149,19 +150,17 @@ function SidebarAnchor(props: {
   collapse?: ReactElement;
   onClick?: () => void;
 }) {
+  const router = useRouter();
   const href = useHref(props.href ?? "");
   const className = cn("relative group flex items-center pr-5 gap-2 py-2 pl-3");
 
   const element = props.href ? (
-    <NavLink
-      end
-      to={href}
+    <Link
+      href={href}
       onClick={props.collapse ? props.onClick : undefined}
-      className={({ isActive }) =>
-        cn(className, {
-          "nav-link-active": isActive,
-        })
-      }
+      className={cn(className, {
+        "nav-link-active": router.asPath === href,
+      })}
     />
   ) : (
     <div
diff --git a/website/app/components/TableOfContents.tsx b/website/src/components/TableOfContents.tsx
similarity index 100%
rename from website/app/components/TableOfContents.tsx
rename to website/src/components/TableOfContents.tsx
diff --git a/website/app/components/Tabs.tsx b/website/src/components/Tabs.tsx
similarity index 87%
rename from website/app/components/Tabs.tsx
rename to website/src/components/Tabs.tsx
index 113c994b..24edaaf0 100644
--- a/website/app/components/Tabs.tsx
+++ b/website/src/components/Tabs.tsx
@@ -1,7 +1,6 @@
-import { NavLink } from "@remix-run/react";
-import { MenuIcon } from "lucide-react";
+import Link from "next/link";
 import { useActiveTab, usePageContext, useTabs } from "~/context";
-import { cn, getHref, isExternalLink } from "~/utils";
+import { cn, getHref } from "~/utils";
 import { MenuToggle } from "./MenuToggle";
 
 type Props = {
@@ -29,8 +28,8 @@ export function Tabs(props: Props) {
 
               return (
                 <li key={href}>
-                  <NavLink
-                    to={href}
+                  <Link
+                    href={href}
                     className={cn(
                       "relative top-px flex items-center h-12 border-b-[1.5px] border-transparent",
                       {
@@ -41,7 +40,7 @@ export function Tabs(props: Props) {
                     )}
                   >
                     {tab.title}
-                  </NavLink>
+                  </Link>
                 </li>
               );
             })}
diff --git a/website/app/components/Theme.tsx b/website/src/components/Theme.tsx
similarity index 98%
rename from website/app/components/Theme.tsx
rename to website/src/components/Theme.tsx
index ad8954c9..959bb9c0 100644
--- a/website/app/components/Theme.tsx
+++ b/website/src/components/Theme.tsx
@@ -1,7 +1,7 @@
 import { Switch } from "@headlessui/react";
 import Color from "color";
 import { MoonIcon, SunDimIcon } from "lucide-react";
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
 import { type Context, usePageContext } from "~/context";
 import { useInlineScript } from "~/hooks";
 import { cn } from "~/utils";
@@ -95,7 +95,7 @@ export function ThemeToggle() {
   const key = getThemeKey(ctx);
   const [enabled, setEnabled] = useState<boolean>();
 
-  useLayoutEffect(() => {
+  useEffect(() => {
     const isDark =
       document.documentElement.getAttribute("data-theme") === "dark";
     setEnabled(isDark);
diff --git a/website/app/components/mdx/Accordion.tsx b/website/src/components/mdx/Accordion.tsx
similarity index 100%
rename from website/app/components/mdx/Accordion.tsx
rename to website/src/components/mdx/Accordion.tsx
diff --git a/website/app/components/mdx/Callout.tsx b/website/src/components/mdx/Callout.tsx
similarity index 100%
rename from website/app/components/mdx/Callout.tsx
rename to website/src/components/mdx/Callout.tsx
diff --git a/website/app/components/mdx/Card.tsx b/website/src/components/mdx/Card.tsx
similarity index 100%
rename from website/app/components/mdx/Card.tsx
rename to website/src/components/mdx/Card.tsx
diff --git a/website/app/components/mdx/CodeBlock.tsx b/website/src/components/mdx/CodeBlock.tsx
similarity index 100%
rename from website/app/components/mdx/CodeBlock.tsx
rename to website/src/components/mdx/CodeBlock.tsx
diff --git a/website/app/components/mdx/CodeGroup.tsx b/website/src/components/mdx/CodeGroup.tsx
similarity index 100%
rename from website/app/components/mdx/CodeGroup.tsx
rename to website/src/components/mdx/CodeGroup.tsx
diff --git a/website/app/components/mdx/Heading.tsx b/website/src/components/mdx/Heading.tsx
similarity index 100%
rename from website/app/components/mdx/Heading.tsx
rename to website/src/components/mdx/Heading.tsx
diff --git a/website/app/components/mdx/Image.tsx b/website/src/components/mdx/Image.tsx
similarity index 100%
rename from website/app/components/mdx/Image.tsx
rename to website/src/components/mdx/Image.tsx
diff --git a/website/app/components/mdx/InvalidComponent.tsx b/website/src/components/mdx/InvalidComponent.tsx
similarity index 95%
rename from website/app/components/mdx/InvalidComponent.tsx
rename to website/src/components/mdx/InvalidComponent.tsx
index 3ade06dc..23788359 100644
--- a/website/app/components/mdx/InvalidComponent.tsx
+++ b/website/src/components/mdx/InvalidComponent.tsx
@@ -1,5 +1,4 @@
 import type { ComponentProps } from "react";
-import { Error } from "./Callout";
 
 type InvalidComponentProps = ComponentProps<"div"> & {
   // The name of the original component, provided by the bundler.
diff --git a/website/app/components/mdx/Link.tsx b/website/src/components/mdx/Link.tsx
similarity index 100%
rename from website/app/components/mdx/Link.tsx
rename to website/src/components/mdx/Link.tsx
diff --git a/website/app/components/mdx/Property.tsx b/website/src/components/mdx/Property.tsx
similarity index 100%
rename from website/app/components/mdx/Property.tsx
rename to website/src/components/mdx/Property.tsx
diff --git a/website/app/components/mdx/Section.tsx b/website/src/components/mdx/Section.tsx
similarity index 100%
rename from website/app/components/mdx/Section.tsx
rename to website/src/components/mdx/Section.tsx
diff --git a/website/app/components/mdx/Steps.tsx b/website/src/components/mdx/Steps.tsx
similarity index 100%
rename from website/app/components/mdx/Steps.tsx
rename to website/src/components/mdx/Steps.tsx
diff --git a/website/app/components/mdx/Table.tsx b/website/src/components/mdx/Table.tsx
similarity index 100%
rename from website/app/components/mdx/Table.tsx
rename to website/src/components/mdx/Table.tsx
diff --git a/website/app/components/mdx/Tabs.tsx b/website/src/components/mdx/Tabs.tsx
similarity index 100%
rename from website/app/components/mdx/Tabs.tsx
rename to website/src/components/mdx/Tabs.tsx
diff --git a/website/app/components/mdx/Tweet.tsx b/website/src/components/mdx/Tweet.tsx
similarity index 100%
rename from website/app/components/mdx/Tweet.tsx
rename to website/src/components/mdx/Tweet.tsx
diff --git a/website/app/components/mdx/Video.tsx b/website/src/components/mdx/Video.tsx
similarity index 100%
rename from website/app/components/mdx/Video.tsx
rename to website/src/components/mdx/Video.tsx
diff --git a/website/app/components/mdx/Vimeo.tsx b/website/src/components/mdx/Vimeo.tsx
similarity index 100%
rename from website/app/components/mdx/Vimeo.tsx
rename to website/src/components/mdx/Vimeo.tsx
diff --git a/website/app/components/mdx/YouTube.tsx b/website/src/components/mdx/YouTube.tsx
similarity index 100%
rename from website/app/components/mdx/YouTube.tsx
rename to website/src/components/mdx/YouTube.tsx
diff --git a/website/app/components/mdx/Zapp.tsx b/website/src/components/mdx/Zapp.tsx
similarity index 100%
rename from website/app/components/mdx/Zapp.tsx
rename to website/src/components/mdx/Zapp.tsx
diff --git a/website/app/context.ts b/website/src/context.ts
similarity index 67%
rename from website/app/context.ts
rename to website/src/context.ts
index 61eb2f04..635fd598 100644
--- a/website/app/context.ts
+++ b/website/src/context.ts
@@ -1,6 +1,17 @@
+import type { GetServerSidePropsContext } from "next/types";
 import { createContext, useContext, useEffect, useState } from "react";
-import type { BundlerOutput, SidebarGroup } from "./api";
-import { getAssetSrc, getHref, getLocale, isExternalLink } from "./utils";
+import domains from "../../domains.json";
+import { type BundlerOutput, type SidebarGroup, getBundle } from "./api";
+import { getEnvironment } from "./env";
+import { trackPageRequest } from "./plausible";
+import {
+  Redirect,
+  ensureLeadingSlash,
+  getAssetSrc,
+  getHref,
+  getLocale,
+  isExternalLink,
+} from "./utils";
 
 type BaseContext = {
   // The relative path of the current page, e.g. `/contributing`.
@@ -22,17 +33,95 @@ export type DocsPageContext = BaseContext & {
   // The repository name, e.g. `docs.page`.
   repository: string;
   // The branch or tag of the repository, e.g. `main`.
-  ref?: string;
+  ref: string | null;
   // The domain assigned to the repository, e.g. `use.docs.page`.
-  domain?: string;
+  domain: string | null;
   // Whether the page is using a vanity domain, e.g. `:org.docs.page/repo`
-  vanity?: boolean;
+  vanity: boolean;
   // The page is not in preview mode.
   preview: false;
 };
 
 export type Context = DocsPageContext | PreviewContext;
 
+export async function getRequestContext(
+  request: GetServerSidePropsContext["req"],
+  opts: {
+    owner: string;
+    repository: string;
+    path: string;
+    ref: string | undefined;
+  }
+) {
+  const { owner, repository, ref, path } = opts;
+
+  const vanity = request.headers["x-docs-page-vanity-domain"] !== undefined;
+
+  const bundle = await getBundle({
+    owner,
+    repository,
+    path,
+    ref,
+  });
+
+  // Get the current environment.
+  const environment = getEnvironment();
+
+  // Check whether the repository has a domain assigned.
+  const domain = domains
+    .find(([, repo]) => repo === `${owner}/${repository}`)
+    ?.at(0);
+
+  // Check if the user has set a redirect in the frontmatter of this page.
+  const redirectTo =
+    typeof bundle.frontmatter.redirect === "string"
+      ? bundle.frontmatter.redirect
+      : undefined;
+
+  // Redirect to the specified URL.
+  if (redirectTo && redirectTo.length > 0) {
+    if (redirectTo.startsWith("http://") || redirectTo.startsWith("https://")) {
+      throw new Redirect(redirectTo);
+    }
+
+    let url = "";
+    if (vanity) {
+      url = `https://${owner}.docs.page/${repository}`;
+      if (ref) url += `~${ref}`;
+      url += redirectTo;
+    } else if (domain && environment === "production") {
+      // If there is a domain setup, always redirect to it.
+      url = `https://${domain}`;
+      if (ref) url += `/~${ref}`;
+      url += redirectTo;
+    } else {
+      const requestUrl = new URL(request.url!);
+      // If no domain, redirect to docs.page.
+      url = `${requestUrl.origin}/${owner}/${repository}`;
+      if (ref) url += `~${ref}`;
+      url += redirectTo;
+    }
+
+    throw new Redirect(url);
+  }
+
+  if (process.env.NODE_ENV === "production") {
+    // Track the page request.
+    await trackPageRequest(request, owner, repository);
+  }
+
+  return {
+    path: ensureLeadingSlash(path),
+    owner,
+    repository,
+    ref: ref || null,
+    domain: domain && environment === "production" ? domain : null,
+    vanity,
+    bundle,
+    preview: false,
+  } satisfies Context;
+}
+
 export const PageContext = createContext<Context | undefined>(undefined);
 
 // Returns the current page context.
@@ -41,7 +130,7 @@ export function usePageContext(): Context {
 
   if (!context) {
     throw new Error(
-      "usePageContext must be used within a PageContext.Provider",
+      "usePageContext must be used within a PageContext.Provider"
     );
   }
 
@@ -55,7 +144,7 @@ export function useAssetSrc(path: string) {
   const isExternal = isExternalLink(path);
 
   const [src, setSrc] = useState(
-    isExternal || !isPreview ? getAssetSrc(ctx, path) : "",
+    isExternal || !isPreview ? getAssetSrc(ctx, path) : ""
   );
 
   useEffect(() => {
diff --git a/website/src/env.ts b/website/src/env.ts
new file mode 100644
index 00000000..9f644710
--- /dev/null
+++ b/website/src/env.ts
@@ -0,0 +1,38 @@
+export type SharedEnvironmentVariables = {
+  VERCEL: string | null;
+  VERCEL_ENV?: string | null;
+  VERCEL_GIT_COMMIT_SHA: string | null;
+};
+
+declare global {
+  interface Window {
+    ENV: SharedEnvironmentVariables;
+  }
+}
+
+// Returns the shared environment variables, from either the server or the client.
+export function getSharedEnvironmentVariables(): SharedEnvironmentVariables {
+  return typeof window === "undefined"
+    ? {
+        VERCEL: process.env.VERCEL || null,
+        VERCEL_ENV: process.env.VERCEL_ENV || null,
+        VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA || null,
+      }
+    : window.ENV;
+}
+
+// Returns the current environment, either `production`, `preview` or `development`.
+export function getEnvironment() {
+  const ENV = getSharedEnvironmentVariables();
+
+  return ENV.VERCEL
+    ? ENV.VERCEL_ENV === "production"
+      ? "production"
+      : "preview"
+    : "development";
+}
+
+// Returns the current build hash, either the Vercel Git commit SHA or `development`.
+export function getBuildHash() {
+  return getSharedEnvironmentVariables().VERCEL_GIT_COMMIT_SHA || "development";
+}
diff --git a/website/app/hooks.tsx b/website/src/hooks.tsx
similarity index 90%
rename from website/app/hooks.tsx
rename to website/src/hooks.tsx
index 97994b4e..1fcc047c 100644
--- a/website/app/hooks.tsx
+++ b/website/src/hooks.tsx
@@ -1,4 +1,4 @@
-import { RefObject, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 
 const scripts: Record<string, Promise<void>> = {};
 
@@ -35,23 +35,10 @@ function loadScript(src: string): Promise<void> {
   return scripts[src];
 }
 
-// Hook to load an external script if it hasn't been loaded yet.
-export function useExternalScript(src: string): boolean {
-  const [loaded, setLoaded] = useState(false);
-
-  useEffect(() => {
-    loadScript(src)
-      .then(() => setLoaded(true))
-      .catch(console.error);
-  }, [src]);
-
-  return loaded;
-}
-
 export function useInlineScript(script: string) {
   const ref = useRef<HTMLDivElement>(null);
 
-  useLayoutEffect(() => {
+  useEffect(() => {
     if (ref.current) {
       ref.current.innerHTML = "";
       const range = document.createRange();
@@ -63,3 +50,15 @@ export function useInlineScript(script: string) {
 
   return <div ref={ref} dangerouslySetInnerHTML={{ __html: script }} />;
 }
+
+export function useExternalScript(src: string): boolean {
+  const [loaded, setLoaded] = useState(false);
+
+  useEffect(() => {
+    loadScript(src)
+      .then(() => setLoaded(true))
+      .catch(console.error);
+  }, [src]);
+
+  return loaded;
+}
diff --git a/website/src/layouts/Documentation.tsx b/website/src/layouts/Documentation.tsx
new file mode 100644
index 00000000..91a46d66
--- /dev/null
+++ b/website/src/layouts/Documentation.tsx
@@ -0,0 +1,212 @@
+import dynamic from "next/dynamic";
+import Head from "next/head";
+import Script from "next/script";
+import { useState } from "react";
+import { Content } from "~/components/Content";
+import { Edit } from "~/components/Edit";
+import { Footer } from "~/components/Footer";
+import { Header } from "~/components/Header";
+import { PreviousNext } from "~/components/PreviousNext";
+import { Scripts } from "~/components/Scripts";
+import { Sidebar } from "~/components/Sidebar";
+import { TableOfContents } from "~/components/TableOfContents";
+import { Tabs } from "~/components/Tabs";
+import { ThemeScripts } from "~/components/Theme";
+import { type Context, PageContext, useTabs } from "~/context";
+import { cn, getAssetSrc } from "~/utils";
+
+import "nprogress/nprogress.css";
+
+const NProgress = dynamic(() => import("~/components/NProgress"), {
+  ssr: false,
+});
+
+export function Documentation({ ctx }: { ctx: Context }) {
+  // Get the page title, starting with page frontmatter, then the config, and finally defaulting to "Documentation".
+  const title =
+    ctx.bundle.frontmatter.title || ctx.bundle.config.name || "Documentation";
+
+  // Get the page description, starting with page frontmatter, then the config, and finally defaulting to undefined.
+  const description =
+    ctx.bundle.frontmatter.description || ctx.bundle.config.description;
+
+  // Get the page image, starting with page frontmatter, then the config, and finally defaulting to the social preview.
+  let image = ctx.bundle.frontmatter.image
+    ? String(ctx.bundle.frontmatter.image)
+    : ctx.bundle.config.socialPreview;
+
+  // If there is no image, generate one.
+  if (image === undefined && !ctx.preview) {
+    const params = JSON.stringify({
+      owner: ctx.owner,
+      repository: ctx.repository,
+      title,
+      description,
+      // Use the light logo, and fallback to the dark logo
+      logo:
+        ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark
+          ? getAssetSrc(
+              ctx,
+              ctx.bundle.config.logo.light || ctx.bundle.config.logo.dark || "",
+            )
+          : undefined,
+    });
+
+    const base64String =
+      typeof window !== "undefined"
+        ? window.btoa(params)
+        : Buffer.from(params).toString("base64");
+
+    image = `https://og.docs.page?params=${base64String}`;
+  }
+  // If it has been set to false, disable the image.
+  else if (image === false) {
+    image = undefined;
+  }
+  // Otherwise the image is a path, so we need to resolve it.
+  else {
+    image = getAssetSrc(ctx, String(image));
+  }
+
+  const noindex =
+    ctx.bundle.frontmatter.noindex === true ||
+    ctx.bundle.config.seo?.noindex === true;
+
+  return (
+    <PageContext.Provider value={ctx}>
+      <Head>
+        <title>{String(title)}</title>
+        <meta property="og:title" content={String(title)} />
+        <meta name="twitter:title" content={String(title)} />
+
+        {description ? (
+          <>
+            <meta name="description" content={String(description)} />
+            <meta property="og:description" content={String(description)} />
+            <meta name="twitter:description" content={String(description)} />
+          </>
+        ) : null}
+
+        {image ? (
+          <>
+            <meta property="og:image" content={image} />
+            <meta name="twitter:card" content="summary_large_image" />
+            <meta name="twitter:image" content={image} />
+          </>
+        ) : null}
+
+        {ctx.bundle.config.favicon?.light ? (
+          <link
+            rel="icon"
+            media={
+              ctx.bundle.config.favicon?.dark
+                ? // If there is a dark favicon, add a media query to prefer light mode only.
+                  "(prefers-color-scheme: light)"
+                : undefined
+            }
+            href={getAssetSrc(ctx, ctx.bundle.config.favicon.light)}
+          />
+        ) : null}
+
+        {ctx.bundle.config.favicon?.dark ? (
+          <link
+            rel="icon"
+            media={
+              ctx.bundle.config.favicon?.light
+                ? // If there is a light favicon, add a media query to prefer dark mode only.
+                  "(prefers-color-scheme: dark)"
+                : undefined
+            }
+            href={getAssetSrc(ctx, ctx.bundle.config.favicon.dark)}
+          />
+        ) : null}
+
+        {noindex ? <meta name="robots" content="noindex" /> : null}
+      </Head>
+      <Script
+        dangerouslySetInnerHTML={{
+          __html: `window.__docsPage = ${JSON.stringify(ctx)}`,
+        }}
+      />
+      <Scripts />
+      <NProgress />
+      <DocumentationLayout />
+    </PageContext.Provider>
+  );
+}
+
+export function DocumentationLayout() {
+  const hasTabs = useTabs().length > 0;
+  const [sidebar, setSidebar] = useState(false);
+
+  function toggleSidebar() {
+    setSidebar((prev) => {
+      const open = !prev;
+
+      if (open) {
+        document.body.style.overflow = "hidden";
+      } else {
+        document.body.style.overflow = "";
+      }
+
+      return open;
+    });
+  }
+
+  return (
+    <>
+      <ThemeScripts />
+      <Scripts />
+      <section className="fixed z-10 inset-x-0 top-0 bg-background-dark/90 backdrop-blur">
+        <Header onMenuToggle={toggleSidebar} />
+        <Tabs onMenuToggle={toggleSidebar} />
+      </section>
+      <div className="max-w-8xl mx-auto px-5">
+        <section
+          className={cn(
+            "fixed z-10 w-[17rem] bottom-0 overflow-y-auto translate-x-[-19rem] lg:translate-x-0 transition-transform",
+            {
+              "top-16": !hasTabs && !sidebar,
+              "top-28": hasTabs && !sidebar,
+              "translate-x-0 top-0 z-20 bg-background border-r border-black/10 dark:border-white/10":
+                sidebar,
+            },
+          )}
+        >
+          <Sidebar onMenuToggle={toggleSidebar} />
+        </section>
+        <div
+          className={cn("relative lg:pl-[17rem]", {
+            "pt-16": !hasTabs,
+            "pt-28": hasTabs,
+          })}
+        >
+          <div
+            role="button"
+            className={cn(
+              "bg-background/50 z-10 absolute inset-0 lg:opacity-0 transition-opacity",
+              {
+                "pointer-events-none opacity-0": !sidebar,
+                "pointer-events-auto opacity-100": sidebar,
+              },
+            )}
+            onClick={() => toggleSidebar()}
+            onKeyDown={() => toggleSidebar()}
+          />
+          <section className="pt-8 ps-4 lg:ps-16 pe-4 flex">
+            <div className="min-w-0 flex-1 pr-0 xl:pr-12">
+              <Content />
+              <Edit />
+              <PreviousNext />
+              <div className="h-px bg-black/5 dark:bg-white/5 my-12" />
+              <Footer />
+            </div>
+            <div className="hidden xl:block relative lg:w-[17rem]">
+              <TableOfContents />
+            </div>
+          </section>
+        </div>
+      </div>
+    </>
+  );
+}
diff --git a/website/app/layouts/ErrorLayout.tsx b/website/src/layouts/Error.tsx
similarity index 98%
rename from website/app/layouts/ErrorLayout.tsx
rename to website/src/layouts/Error.tsx
index 2cc1afe4..fff0df82 100644
--- a/website/app/layouts/ErrorLayout.tsx
+++ b/website/src/layouts/Error.tsx
@@ -20,7 +20,7 @@ type Props =
       error: BundleErrorResponse;
     };
 
-export function ErrorLayout(props: Props) {
+export function Error(props: Props) {
   // If an error is thrown from the bundler, we can render
   // specific error messages based on the error code.
   if ("error" in props) {
diff --git a/website/app/layouts/Footer.tsx b/website/src/layouts/Footer.tsx
similarity index 98%
rename from website/app/layouts/Footer.tsx
rename to website/src/layouts/Footer.tsx
index 6f6c1012..9ca45af8 100644
--- a/website/app/layouts/Footer.tsx
+++ b/website/src/layouts/Footer.tsx
@@ -5,7 +5,7 @@ export function Footer() {
     <footer className="max-w-3xl mx-auto grid grid-cols-1 md:grid-cols-2 py-12">
       <div className="w-full mx-auto md:flex justify-end md:border-r border-white/10 md:pr-12">
         <div className="flex items-center gap-3 justify-center">
-          <img src="/logo.png" alt="Logo" className="h-6" />
+          <img src="/_docs.page/logo.png" alt="Logo" className="h-6" />
           <svg
             xmlns="http://www.w3.org/2000/svg"
             width="87"
diff --git a/website/app/layouts/Header.tsx b/website/src/layouts/Header.tsx
similarity index 95%
rename from website/app/layouts/Header.tsx
rename to website/src/layouts/Header.tsx
index e289203d..a068b9d8 100644
--- a/website/app/layouts/Header.tsx
+++ b/website/src/layouts/Header.tsx
@@ -1,12 +1,12 @@
-import { Link, NavLink } from "@remix-run/react";
 import { ChevronRightIcon } from "lucide-react";
+import Link from "next/link";
 import { Icon } from "~/components/Icon";
 
 export function Header() {
   return (
     <header className="max-w-5xl mx-auto py-8 px-3 flex flex-col sm:flex-row gap-6 items-center">
-      <Link to="/" className="group flex items-center gap-3">
-        <img src="/logo.png" alt="docs.page logo" className="h-6" />
+      <Link href="/" className="group flex items-center gap-3">
+        <img src="/_docs.page/logo.png" alt="docs.page logo" className="h-6" />
         <svg
           xmlns="http://www.w3.org/2000/svg"
           width="87"
@@ -56,15 +56,15 @@ export function Header() {
             </a>
           </li>
           <li>
-            <NavLink
-              to="/preview"
+            <Link
+              href="/preview"
               className="flex items-center gap-1 hover:opacity-75 transition-opacity border border-brand-50/90 px-4 py-1.5 rounded-full"
             >
               <span className="flex items-center gap-1">
                 <span>Local Preview</span>
                 <ChevronRightIcon size={16} className="inline" />
               </span>
-            </NavLink>
+            </Link>
           </li>
         </ul>
         {/* <div className="border rounded-md py-3 px-2">
diff --git a/website/src/layouts/Site.tsx b/website/src/layouts/Site.tsx
new file mode 100644
index 00000000..d8783f0e
--- /dev/null
+++ b/website/src/layouts/Site.tsx
@@ -0,0 +1,40 @@
+import Head from "next/head";
+import { useInlineScript } from "~/hooks";
+
+const title = "docs.page | Ship documentation, like you ship code";
+const description =
+  "Publish beautiful online documentation instantly, from your code editor using markdown and a public GitHub repository.";
+const image = "https://docs.page/_docs.page/social-preview.png";
+
+export function Site({ children }: { children: React.ReactNode }) {
+  const scripts = useInlineScript(`<script>(() => {
+		document.documentElement.setAttribute('data-theme', 'dark');
+    const root = document.documentElement;
+		root.style.setProperty('--background-dark', '224, 71%, 4%');		
+	})()</script>`);
+
+  return (
+    <>
+      <Head>
+        <title>{title}</title>
+        <meta name="description" content={description} />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={description} />
+        <meta property="og:image" content={image} />
+
+        <meta name="twitter:card" content="summary_large_image" />
+        <meta name="twitter:title" content={title} />
+        <meta name="twitter:description" content={description} />
+        <meta name="twitter:image" content={image} />
+        <meta name="twitter:site" content="@invertaseio" />
+      </Head>
+      {scripts}
+      <script
+        defer
+        data-domain="docs.page"
+        src="https://plausible.io/js/script.js"
+      />
+      {children}
+    </>
+  );
+}
diff --git a/website/app/routes/_layout.get-started/Card.tsx b/website/src/layouts/get-started/Card.tsx
similarity index 100%
rename from website/app/routes/_layout.get-started/Card.tsx
rename to website/src/layouts/get-started/Card.tsx
diff --git a/website/app/routes/_layout.get-started/route.tsx b/website/src/layouts/get-started/index.tsx
similarity index 89%
rename from website/app/routes/_layout.get-started/route.tsx
rename to website/src/layouts/get-started/index.tsx
index ce3e890c..d7c4195a 100644
--- a/website/app/routes/_layout.get-started/route.tsx
+++ b/website/src/layouts/get-started/index.tsx
@@ -1,6 +1,3 @@
-import { type ComponentProps, useEffect, useState } from "react";
-
-import { NavLink } from "@remix-run/react";
 import {
   BookIcon,
   CheckIcon,
@@ -8,26 +5,18 @@ import {
   ExternalLinkIcon,
   EyeIcon,
 } from "lucide-react";
+import Link from "next/link";
+import { type ComponentProps, useEffect, useState } from "react";
 import { useInlineScript } from "~/hooks";
 import { Footer } from "~/layouts/Footer";
 import { Header } from "~/layouts/Header";
-import { getLinkDescriptors, getMetadata } from "~/meta";
 import { cn } from "~/utils";
+import { Site } from "../Site";
 import { Card } from "./Card";
 
-export const links = getLinkDescriptors;
-export const meta = getMetadata;
-
 export default function GetStartedRoute() {
-  const scripts = useInlineScript(`<script>(() => {
-		document.documentElement.setAttribute('data-theme', 'dark');
-    const root = document.documentElement;
-		root.style.setProperty('--background-dark', '224, 71%, 4%');		
-	})()</script>`);
-
   return (
-    <>
-      {scripts}
+    <Site>
       <Header />
       <section className="max-w-5xl w-full mx-auto py-20 px-6">
         <div className="">
@@ -44,7 +33,7 @@ export default function GetStartedRoute() {
         </div>
       </section>
       <Footer />
-    </>
+    </Site>
   );
 }
 
@@ -68,7 +57,7 @@ function Install() {
       description="Add docs.page to your project"
       asset={
         <img
-          src="/assets/get-started/terminal.png"
+          src="/_docs.page/assets/get-started/terminal.png"
           alt="Terminal Command"
           className=""
         />
@@ -116,7 +105,7 @@ function AddContent() {
       description="Add markdown to a page"
       asset={
         <img
-          src="/assets/get-started/add-content-editor.png"
+          src="/_docs.page/assets/get-started/add-content-editor.png"
           alt="Markdown"
           className="w-full"
         />
@@ -153,7 +142,7 @@ function PreviewDocs() {
       description="Preview your docs.page site"
       asset={
         <img
-          src="/assets/get-started/preview.png"
+          src="/_docs.page/assets/get-started/preview.png"
           alt="Markdown"
           className="w-full"
         />
@@ -161,9 +150,9 @@ function PreviewDocs() {
       meta={
         <p>
           View your documentation locally before publishing it to the web using{" "}
-          <NavLink to="/preview" className="underline">
+          <Link href="/preview" className="underline">
             Local Preview
-          </NavLink>{" "}
+          </Link>{" "}
           mode.
         </p>
       }
@@ -194,7 +183,7 @@ function PublishChanges() {
       description="Make your changes public"
       asset={
         <img
-          src="/assets/get-started/publish.png"
+          src="/_docs.page/assets/get-started/publish.png"
           alt="Markdown"
           className="w-full"
         />
diff --git a/website/app/routes/_layout._index/Afilliation.tsx b/website/src/layouts/homepage/Affiliation.tsx
similarity index 100%
rename from website/app/routes/_layout._index/Afilliation.tsx
rename to website/src/layouts/homepage/Affiliation.tsx
diff --git a/website/app/routes/_layout._index/CallToAction.tsx b/website/src/layouts/homepage/CallToAction.tsx
similarity index 94%
rename from website/app/routes/_layout._index/CallToAction.tsx
rename to website/src/layouts/homepage/CallToAction.tsx
index 84732433..17e28d3f 100644
--- a/website/app/routes/_layout._index/CallToAction.tsx
+++ b/website/src/layouts/homepage/CallToAction.tsx
@@ -1,5 +1,5 @@
 import { HandshakeIcon } from "lucide-react";
-import { Button } from "../../components/Button";
+import { Button } from "~/components/Button";
 
 export function CallToAction() {
   return (
diff --git a/website/app/routes/_layout._index/Demo.tsx b/website/src/layouts/homepage/Demo.tsx
similarity index 82%
rename from website/app/routes/_layout._index/Demo.tsx
rename to website/src/layouts/homepage/Demo.tsx
index 2cd168d2..a6e44994 100644
--- a/website/app/routes/_layout._index/Demo.tsx
+++ b/website/src/layouts/homepage/Demo.tsx
@@ -14,7 +14,10 @@ export function Demo() {
           preload="metadata"
           className="w-full rounded-xl"
         >
-          <source src="/docs-page-hero-video.webm#t=1" type="video/webm" />
+          <source
+            src="/_docs.page/docs-page-hero-video.webm#t=1"
+            type="video/webm"
+          />
         </video>
       </div>
     </section>
diff --git a/website/app/routes/_layout._index/Features.tsx b/website/src/layouts/homepage/Features.tsx
similarity index 99%
rename from website/app/routes/_layout._index/Features.tsx
rename to website/src/layouts/homepage/Features.tsx
index 446c3708..13c350a7 100644
--- a/website/app/routes/_layout._index/Features.tsx
+++ b/website/src/layouts/homepage/Features.tsx
@@ -6,7 +6,6 @@ import {
   Grid2X2Icon,
   Heading1Icon,
   PencilIcon,
-  RefreshCcwDot,
   RefreshCcwDotIcon,
   SearchIcon,
   SwatchBookIcon,
diff --git a/website/app/routes/_layout._index/Hero.tsx b/website/src/layouts/homepage/Hero.tsx
similarity index 86%
rename from website/app/routes/_layout._index/Hero.tsx
rename to website/src/layouts/homepage/Hero.tsx
index f253de3a..d45e5117 100644
--- a/website/app/routes/_layout._index/Hero.tsx
+++ b/website/src/layouts/homepage/Hero.tsx
@@ -1,4 +1,4 @@
-import { Button } from "../../components/Button";
+import { Button } from "~/components/Button";
 
 export function Hero() {
   return (
@@ -17,12 +17,12 @@ export function Hero() {
       </p>
       <div className="flex flex-col sm:flex-row justify-center gap-3">
         <div className="flex justify-center">
-          <Button as="a" cta href="/get-started">
+          <Button element="a" cta href="/get-started">
             Start Publishing
           </Button>
         </div>
         <div className="flex justify-center">
-          <Button as="a" href="https://use.docs.page">
+          <Button element="a" href="https://use.docs.page">
             Documentation
           </Button>
         </div>
diff --git a/website/app/routes/_layout._index/Platform.tsx b/website/src/layouts/homepage/Platform.tsx
similarity index 89%
rename from website/app/routes/_layout._index/Platform.tsx
rename to website/src/layouts/homepage/Platform.tsx
index 351fa60b..ddbaf6c5 100644
--- a/website/app/routes/_layout._index/Platform.tsx
+++ b/website/src/layouts/homepage/Platform.tsx
@@ -1,4 +1,4 @@
-import { BookTextIcon, LockIcon } from "lucide-react";
+import { BookTextIcon } from "lucide-react";
 
 const ASSET_VERSION = 2;
 
@@ -89,7 +89,7 @@ function PlatformCard(props: PlatformCardProps) {
 function Manage() {
   return (
     <img
-      src={`/assets/manage-docs-as-code.png?v=${ASSET_VERSION}`}
+      src={`/_docs.page/assets/manage-docs-as-code.png?v=${ASSET_VERSION}`}
       alt="Manage Docs as Code"
       className="absolute inset-x-0 top-[10px]"
     />
@@ -99,7 +99,7 @@ function Manage() {
 function BeautifulByDesign() {
   return (
     <img
-      src={`/assets/beautiful-by-design.png?v=${ASSET_VERSION}`}
+      src={`/_docs.page/assets/beautiful-by-design.png?v=${ASSET_VERSION}`}
       alt="Publish Instantly"
       className="absolute inset-x-0 top-0 lg:top-2 scale-75 lg:scale-100"
     />
@@ -109,7 +109,7 @@ function BeautifulByDesign() {
 function Preview() {
   return (
     <img
-      src={`/assets/collaborate-with-live-preview.png?v=${ASSET_VERSION}`}
+      src={`/_docs.page/assets/collaborate-with-live-preview.png?v=${ASSET_VERSION}`}
       alt="Publish Instantly"
       className="absolute inset-0 top-2 scale-95"
     />
@@ -119,7 +119,7 @@ function Preview() {
 function Publish() {
   return (
     <img
-      src={`/assets/publish-instantly.png?v=${ASSET_VERSION}`}
+      src={`/_docs.page/assets/publish-instantly.png?v=${ASSET_VERSION}`}
       alt="Publish Instantly"
       className="absolute inset-x-0 top-2"
     />
@@ -129,7 +129,7 @@ function Publish() {
 function Customize() {
   return (
     <img
-      src={`/assets/customise-and-theme.png?v=${ASSET_VERSION}`}
+      src={`/_docs.page/assets/customise-and-theme.png?v=${ASSET_VERSION}`}
       alt="Publish Instantly"
     />
   );
diff --git a/website/app/routes/_layout._index/Testimonials.tsx b/website/src/layouts/homepage/Testimonials.tsx
similarity index 100%
rename from website/app/routes/_layout._index/Testimonials.tsx
rename to website/src/layouts/homepage/Testimonials.tsx
diff --git a/website/app/routes/_layout._index/route.tsx b/website/src/layouts/homepage/index.tsx
similarity index 58%
rename from website/app/routes/_layout._index/route.tsx
rename to website/src/layouts/homepage/index.tsx
index c29d1383..3fd42cf1 100644
--- a/website/app/routes/_layout._index/route.tsx
+++ b/website/src/layouts/homepage/index.tsx
@@ -1,20 +1,18 @@
+import { HeroGradient } from "~/components/HeroGradient";
+import { Footer } from "~/layouts/Footer";
 import { Header } from "~/layouts/Header";
-import { getLinkDescriptors, getMetadata } from "~/meta";
-import { Footer } from "../../layouts/Footer";
-import { HeroGradient } from "../../layouts/HeroGradient";
-import { Affiliation } from "./Afilliation";
+import { Site } from "~/layouts/Site";
+
+import { Affiliation } from "./Affiliation";
 import { CallToAction } from "./CallToAction";
 import { Demo } from "./Demo";
 import { Features } from "./Features";
 import { Hero } from "./Hero";
 import { Platform } from "./Platform";
 
-export const links = getLinkDescriptors;
-export const meta = getMetadata;
-
-export default function Homepage() {
+export function Homepage() {
   return (
-    <>
+    <Site>
       <HeroGradient fadeInMs={850} />
       <Header />
       <Hero />
@@ -25,6 +23,6 @@ export default function Homepage() {
       {/* <Testimonials /> */}
       <CallToAction />
       <Footer />
-    </>
+    </Site>
   );
 }
diff --git a/website/app/routes/_layout.preview.$/Toolbar.tsx b/website/src/layouts/preview/Toolbar.tsx
similarity index 98%
rename from website/app/routes/_layout.preview.$/Toolbar.tsx
rename to website/src/layouts/preview/Toolbar.tsx
index 3fdef51a..ca7acc1a 100644
--- a/website/app/routes/_layout.preview.$/Toolbar.tsx
+++ b/website/src/layouts/preview/Toolbar.tsx
@@ -6,8 +6,8 @@ import {
   Transition,
   TransitionChild,
 } from "@headlessui/react";
-import { useNavigate } from "@remix-run/react";
 import { ListTreeIcon, OctagonXIcon, TriangleAlert } from "lucide-react";
+import { useRouter } from "next/router";
 import { Fragment, useState } from "react";
 import { useCheckResult, useFiles, useRestart } from "./utils";
 
@@ -29,14 +29,14 @@ export function Toolbar() {
 
 function Restart() {
   const restart = useRestart();
-  const navigate = useNavigate();
+  const router = useRouter();
 
   return (
     <button
       type="button"
       onClick={async () => {
         await restart.mutateAsync();
-        navigate("/preview");
+        router.push("/preview");
       }}
       title="Restart preview mode"
       className="size-9 hover:bg-black rounded-full flex items-center justify-center"
diff --git a/website/app/routes/_layout.preview.$/route.tsx b/website/src/layouts/preview/index.tsx
similarity index 75%
rename from website/app/routes/_layout.preview.$/route.tsx
rename to website/src/layouts/preview/index.tsx
index b5114dd0..86248093 100644
--- a/website/app/routes/_layout.preview.$/route.tsx
+++ b/website/src/layouts/preview/index.tsx
@@ -1,8 +1,5 @@
-import { NavLink, redirect, useFetcher, useParams } from "@remix-run/react";
 import { QueryClientProvider } from "@tanstack/react-query";
-import type { ActionFunctionArgs, MetaFunction } from "@vercel/remix";
 import { useEffect, useState } from "react";
-import { getPreviewBundle } from "../../api";
 import { useTrigger } from "./trigger";
 import {
   ConfigurationFileNotFoundError,
@@ -16,29 +13,17 @@ import {
   useSelectDirectory,
 } from "./utils";
 
-import docsearch from "@docsearch/css/dist/style.css?url";
+import Link from "next/link";
+import { useRouter } from "next/router";
 import { Button } from "~/components/Button";
 import { PageContext } from "~/context";
-import { DocsLayout } from "~/layouts/DocsLayout";
 import { Footer } from "~/layouts/Footer";
 import { Header } from "~/layouts/Header";
-import { getLinkDescriptors, getMetadata } from "~/meta";
-import { cn, ensureLeadingSlash, isExternalLink } from "~/utils";
+import { cn, ensureLeadingSlash } from "~/utils";
+import { DocumentationLayout } from "../Documentation";
+import { Site } from "../Site";
 import { Toolbar } from "./Toolbar";
 
-export const links = getLinkDescriptors;
-
-export const meta: MetaFunction = () => {
-  return [
-    ...getMetadata(),
-    {
-      tagName: "link",
-      rel: "stylesheet",
-      href: docsearch,
-    },
-  ];
-};
-
 export default function PreviewOutlet() {
   return (
     <QueryClientProvider client={queryClient}>
@@ -47,56 +32,18 @@ export default function PreviewOutlet() {
   );
 }
 
-export const action = async (args: ActionFunctionArgs) => {
-  const json = await args.request.json();
-  const bundle = await getPreviewBundle(json).catch((response) => {
-    throw response;
-  });
-
-  // Check if the user has set a redirect in the frontmatter of this page.
-  const redirectTo =
-    typeof bundle.frontmatter.redirect === "string"
-      ? bundle.frontmatter.redirect
-      : undefined;
-
-  // Redirect to the specified URL.
-  if (redirectTo && redirectTo.length > 0) {
-    const url = isExternalLink(String(redirectTo))
-      ? String(redirectTo)
-      : `/preview${ensureLeadingSlash(String(redirectTo))}`;
-
-    throw redirect(url);
-  }
-
-  return {
-    bundle,
-  };
-};
-
 function Preview() {
   const [client, setClient] = useState(false);
-  const params = useParams();
-  const path = params["*"] || "";
+  const router = useRouter();
+  const path = router.asPath.replace("/preview", "");
 
-  const fetcher = useFetcher<typeof action>({ key: "bundle" });
   const directory = useDirectoryHandle();
   const content = usePageContent(path, directory.data);
-  const bundle = fetcher.data?.bundle;
 
   useEffect(() => {
     setClient(true);
   }, []);
 
-  useEffect(() => {
-    if (content.data) {
-      console.log("Submitting content", content.data);
-      fetcher.submit(content.data, {
-        method: "POST",
-        encType: "application/json",
-      });
-    }
-  }, [fetcher.submit, content.data]);
-
   if (
     content.isFetched &&
     content.error &&
@@ -171,24 +118,24 @@ function Preview() {
     );
   }
 
-  if (bundle && directory.data) {
+  if (content.data) {
     return (
       <PageContext.Provider
         value={{
           path: ensureLeadingSlash(path),
-          bundle,
+          bundle: content.data,
           preview: true,
           getFile,
         }}
       >
-        <DocsLayout />
+        <DocumentationLayout />
         <Toolbar />
       </PageContext.Provider>
     );
   }
 
   return (
-    <>
+    <Site>
       <Header />
       <section className="max-w-5xl w-full mx-auto py-20 px-6">
         <div>
@@ -208,14 +155,14 @@ function Preview() {
         <div className="text-center mt-12 opacity-75">
           Need to get started with docs.page?
           <br />
-          <NavLink to="/get-started" className="underline">
+          <Link href="/get-started" className="underline">
             Check out the getting started guide
-          </NavLink>
+          </Link>
           .
         </div>
       </section>
       <Footer />
-    </>
+    </Site>
   );
 }
 
@@ -263,7 +210,7 @@ function Trigger() {
           </p>
         ) : null}
         <Button
-          as="button"
+          element="button"
           type="button"
           cta
           onClick={() => {
@@ -281,7 +228,7 @@ function Trigger() {
   return (
     <Button
       cta
-      as="button"
+      element="button"
       disabled={disabled}
       className={cn({
         "opacity-50": disabled,
diff --git a/website/app/routes/_layout.preview.$/trigger.ts b/website/src/layouts/preview/trigger.ts
similarity index 100%
rename from website/app/routes/_layout.preview.$/trigger.ts
rename to website/src/layouts/preview/trigger.ts
diff --git a/website/app/routes/_layout.preview.$/utils.ts b/website/src/layouts/preview/utils.ts
similarity index 91%
rename from website/app/routes/_layout.preview.$/utils.ts
rename to website/src/layouts/preview/utils.ts
index b9bb9433..c50f3c94 100644
--- a/website/app/routes/_layout.preview.$/utils.ts
+++ b/website/src/layouts/preview/utils.ts
@@ -1,12 +1,18 @@
 import { type CheckResult, check } from "@docs.page/cli";
-import { QueryClient, useMutation, useQuery } from "@tanstack/react-query";
+import {
+  QueryClient,
+  keepPreviousData,
+  useMutation,
+  useQuery,
+} from "@tanstack/react-query";
 import { type DBSchema, type IDBPDatabase, openDB } from "idb";
-import mime from "mime";
+import mime from "mime-types";
+import type { BundleErrorResponse, BundlerOutput } from "~/api";
 import { ensureLeadingSlash } from "~/utils";
 
 const DATABASE = "docs.page";
 const DATABASE_VERSION = 2;
-const REFETCH_INTERVAL = 1000;
+const REFETCH_INTERVAL = false;
 
 interface Database extends DBSchema {
   handles: {
@@ -170,7 +176,7 @@ export function useDirectoryHandle() {
             }
             // Store other files as blob URLs
             else {
-              const type = mime.getType(file.name) ?? undefined;
+              const type = mime.lookup(file.name) || undefined;
               const buffer = await file.arrayBuffer();
               const blob = new Blob([buffer], { type });
               await db.put("files", URL.createObjectURL(blob), key);
@@ -241,6 +247,7 @@ export function usePageContent(
       // If there's an error, don't refetch files.
       return ctx.state.error ? false : REFETCH_INTERVAL;
     },
+    placeholderData: keepPreviousData,
     retry: false,
     queryFn: async () => {
       const db = await openDatabase();
@@ -269,13 +276,30 @@ export function usePageContent(
         throw new FileNotFoundError(filePath, filePaths);
       }
 
-      return {
+      const payload = {
         config: {
           yaml: yamlConfig ?? null,
           json: jsonConfig ?? null,
         },
         markdown: file1 ?? file2 ?? null,
       };
+
+      const response = await fetch(`${location.origin}/api/preview`, {
+        method: "POST",
+        body: JSON.stringify(payload),
+      });
+
+      if (!response.ok) {
+        throw new Error("Failed to fetch preview bundle");
+      }
+
+      const data = await response.json();
+
+      if ("bundle" in data) {
+        return data.bundle as BundlerOutput;
+      }
+
+      throw new Error("Unexpected response from Preview API");
     },
   });
 }
@@ -384,3 +408,11 @@ export class ConfigurationFileNotFoundError extends Error {
     super("Configuration file not found");
   }
 }
+
+export class PreviewBundleError extends Error {
+  error: BundleErrorResponse;
+  constructor(error: BundleErrorResponse) {
+    super("Preview Bundle Error");
+    this.error = error;
+  }
+}
diff --git a/website/src/pages/404.tsx b/website/src/pages/404.tsx
new file mode 100644
index 00000000..8b9c4fde
--- /dev/null
+++ b/website/src/pages/404.tsx
@@ -0,0 +1,10 @@
+import { Error } from "~/layouts/Error";
+
+export default function NotFound() {
+  return (
+    <Error
+      title="Not Found"
+      description="The page you were looking for could not be found."
+    />
+  );
+}
diff --git a/website/src/pages/500.tsx b/website/src/pages/500.tsx
new file mode 100644
index 00000000..b22e6ba9
--- /dev/null
+++ b/website/src/pages/500.tsx
@@ -0,0 +1,10 @@
+import { Error } from "~/layouts/Error";
+
+export default function ServerError() {
+  return (
+    <Error
+      title="Something went wrong"
+      description="An unexpected error occurred. Please try again later."
+    />
+  );
+}
diff --git a/website/src/pages/[...path].tsx b/website/src/pages/[...path].tsx
new file mode 100644
index 00000000..0ba11332
--- /dev/null
+++ b/website/src/pages/[...path].tsx
@@ -0,0 +1,115 @@
+import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
+import type { BundleErrorResponse } from "~/api";
+import { type Context, getRequestContext } from "~/context";
+import type { SharedEnvironmentVariables } from "~/env";
+
+import { Documentation } from "~/layouts/Documentation";
+import { Error } from "~/layouts/Error";
+import { Site } from "~/layouts/Site";
+import { Redirect, bundleCodeToStatusCode } from "~/utils";
+
+export const getServerSideProps = (async ({ params, req, res }) => {
+  const env = {
+    VERCEL: process.env.VERCEL || null,
+    VERCEL_ENV: process.env.VERCEL_ENV || null,
+    VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA || null,
+  } satisfies SharedEnvironmentVariables;
+
+  const param = params?.path ? params.path : [];
+  const chunks = Array.isArray(param) ? param : [param];
+
+  // Any paths with only one chunk are invalid, and should be handled
+  // by other page routes.
+  if (chunks.length === 1) {
+    return { notFound: true };
+  }
+
+  // Any paths with two chunks or more are a repository page.
+  let [owner, repository, ...path] = chunks;
+  let ref: string | undefined;
+
+  // Check if the repo includes a ref (invertase/foo~bar)
+  if (repository.includes("~")) {
+    [repository, ref] = repository.split("~");
+  }
+
+  let ctx: Context | null = null;
+
+  try {
+    ctx = await getRequestContext(req, {
+      owner,
+      repository,
+      ref,
+      path: path.join("/"),
+    });
+  } catch (error) {
+    // If error is a Redirect instance
+    if (error instanceof Redirect) {
+      return {
+        redirect: {
+          destination: error.url,
+          permanent: false,
+        },
+      };
+    }
+
+    // If error is a Response instance
+    if (error instanceof Response) {
+      // Parse the response body as JSON.
+      const body = (await error.json()) as BundleErrorResponse;
+
+      // Set the status code to the bundle error code.
+      res.statusCode = bundleCodeToStatusCode(body);
+
+      return {
+        props: {
+          error: body,
+        },
+      };
+    }
+
+    throw error;
+  }
+
+  // Cache the response for 1 second, and revalidate in the background.
+  res.setHeader(
+    "Cache-Control",
+    "public, s-maxage=1, stale-while-revalidate=59"
+  );
+
+  return { props: { env, ctx } };
+}) satisfies GetServerSideProps<
+  | {
+      env: SharedEnvironmentVariables;
+      ctx: Context;
+    }
+  | {
+      error: BundleErrorResponse;
+    }
+>;
+
+export default function Route({
+  env,
+  ctx,
+  error,
+}: InferGetServerSidePropsType<typeof getServerSideProps>) {
+  // If there is an error, render the error page.
+  if (error) {
+    return (
+      <Site>
+        <Error error={error} />
+      </Site>
+    );
+  }
+
+  return (
+    <>
+      <script
+        dangerouslySetInnerHTML={{
+          __html: `window.ENV = ${env ? JSON.stringify(env) : "{}"}`,
+        }}
+      />
+      <Documentation ctx={ctx} />
+    </>
+  );
+}
diff --git a/website/src/pages/_app.tsx b/website/src/pages/_app.tsx
new file mode 100644
index 00000000..32508aa6
--- /dev/null
+++ b/website/src/pages/_app.tsx
@@ -0,0 +1,6 @@
+import "../styles.css";
+import type { AppProps } from "next/app";
+
+export default function App({ Component, pageProps }: AppProps) {
+  return <Component {...pageProps} />;
+}
diff --git a/website/src/pages/_document.tsx b/website/src/pages/_document.tsx
new file mode 100644
index 00000000..f288ea7a
--- /dev/null
+++ b/website/src/pages/_document.tsx
@@ -0,0 +1,38 @@
+import { Head, Html, Main, NextScript } from "next/document";
+
+export default function Document() {
+  return (
+    <Html lang="en">
+      <Head>
+        <meta charSet="utf-8" />
+        <meta name="theme-color" content="#0B0D0E" />
+        <link rel="preconnect" href="https://fonts.googleapis.com" />
+        <link
+          rel="preconnect"
+          href="https://fonts.gstatic.com"
+          crossOrigin="anonymous"
+        />
+        <link
+          href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=auto"
+          rel="stylesheet"
+        />
+        <link
+          href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap"
+          rel="stylesheet"
+        />
+        <link href="/_docs.page/fa/fontawesome.min.css" rel="stylesheet" />
+        <link href="/_docs.page/fa/brands.min.css" rel="stylesheet" />
+        <link href="/_docs.page/fa/solid.min.css" rel="stylesheet" />
+      </Head>
+      <body
+        className="antialiased bg-background text-gray-900 dark:text-gray-200"
+        style={{
+          textRendering: "optimizeLegibility",
+        }}
+      >
+        <Main />
+        <NextScript />
+      </body>
+    </Html>
+  );
+}
diff --git a/website/src/pages/api/preview.ts b/website/src/pages/api/preview.ts
new file mode 100644
index 00000000..9317c69a
--- /dev/null
+++ b/website/src/pages/api/preview.ts
@@ -0,0 +1,25 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import {
+  type BundleErrorResponse,
+  type BundlerOutput,
+  getPreviewBundle,
+} from "~/api";
+
+type ResponseData = {
+  bundle: BundlerOutput;
+};
+
+export default async function handler(
+  req: NextApiRequest,
+  res: NextApiResponse<ResponseData>,
+) {
+  if (req.method !== "POST") {
+    return res.status(405);
+  }
+
+  const bundle = await getPreviewBundle(JSON.parse(req.body));
+
+  res.status(200).json({
+    bundle,
+  });
+}
diff --git a/website/src/pages/get-started.tsx b/website/src/pages/get-started.tsx
new file mode 100644
index 00000000..c63f69cc
--- /dev/null
+++ b/website/src/pages/get-started.tsx
@@ -0,0 +1,2 @@
+import GetStarted from "~/layouts/get-started";
+export default GetStarted;
diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx
new file mode 100644
index 00000000..875c0c7b
--- /dev/null
+++ b/website/src/pages/index.tsx
@@ -0,0 +1,2 @@
+import { Homepage } from "~/layouts/homepage";
+export default Homepage;
\ No newline at end of file
diff --git a/website/src/pages/preview/[[...path]].tsx b/website/src/pages/preview/[[...path]].tsx
new file mode 100644
index 00000000..7686c3e5
--- /dev/null
+++ b/website/src/pages/preview/[[...path]].tsx
@@ -0,0 +1,2 @@
+import Preview from "~/layouts/preview";
+export default Preview;
diff --git a/website/app/plausible.ts b/website/src/plausible.ts
similarity index 72%
rename from website/app/plausible.ts
rename to website/src/plausible.ts
index 6520a6a5..72c6eecb 100644
--- a/website/app/plausible.ts
+++ b/website/src/plausible.ts
@@ -1,17 +1,21 @@
+import type { GetServerSidePropsContext } from "next";
 import { getClientIp } from "request-ip";
 
 export async function trackPageRequest(
-  request: Request,
+  request: GetServerSidePropsContext["req"],
   owner: string,
   repository: string,
 ): Promise<void> {
+  const userAgent = request.headers["User-Agent"];
+
   try {
     await fetch("https://plausible.io/api/event", {
       method: "POST",
       headers: new Headers({
         "Content-Type": "application/json",
-        "User-Agent": request.headers.get("User-Agent") || "",
-        // @ts-expect-error - request-ip types use none-generic Request
+        "User-Agent": Array.isArray(userAgent)
+          ? userAgent.join(" ")
+          : userAgent || "",
         "X-Forwarded-For": getClientIp(request) ?? "",
       }),
       body: JSON.stringify({
diff --git a/website/app/styles.css b/website/src/styles.css
similarity index 99%
rename from website/app/styles.css
rename to website/src/styles.css
index 3865d49a..5d4338b2 100644
--- a/website/app/styles.css
+++ b/website/src/styles.css
@@ -38,7 +38,7 @@ html[data-theme="dark"] {
 
 /* Apply theme based variables */
 :root {
-  --docsearch-primary-color: hsl(var(--primary));
+  --docsearch-primary-color: hsl(var(--primary)) !important;
   --docsearch-modal-background: hsl(var(--background)) !important;
   --docsearch-footer-background: hsl(var(--background)) !important;
   --docsearch-container-background: hsl(var(--background) / 0.9) !important;
diff --git a/website/app/utils.ts b/website/src/utils.ts
similarity index 54%
rename from website/app/utils.ts
rename to website/src/utils.ts
index 8a2971e1..1d53c120 100644
--- a/website/app/utils.ts
+++ b/website/src/utils.ts
@@ -1,112 +1,17 @@
-import type { LoaderFunctionArgs } from "@remix-run/node";
 import cx from "classnames";
 import { twMerge } from "tailwind-merge";
-import type { Context } from "~/context";
-import domains from "../../domains.json";
 import type { BundleResponse } from "./api";
+import type { Context } from "./context";
 
-// Gets the request params from the incoming request, which could be a client
-// request using a domain, or a server request using a path.
-export function getRequestParams(args: LoaderFunctionArgs) {
-  let owner: string;
-  let repository: string;
-  let ref: string | undefined;
-  let path = args.params["*"] || "";
+export class Redirect extends Error {
+  url: string;
 
-  const url = new URL(args.request.url);
-
-  // A rewritten request comes through with a header containing `x-docs-page-domain`,
-  // which is the domain the request was rewritten from so we treat it as a vanity domain.
-  let vanity = args.request.headers.get("x-docs-page-domain") !== null;
-
-  // If it's a request to localhost, docs.page or staging.docs.page, we can extract
-  // the owner and repository from the URL e.g. https://docs.page/invertase/melos/getting-started
-  if (
-    url.hostname === "localhost" ||
-    url.hostname === "docs.page" ||
-    url.hostname === "staging.docs.page"
-  ) {
-    const chunks = path.split("/");
-    owner = chunks.at(0)!;
-    repository = chunks.at(1)!;
-    path = chunks.slice(2).join("/");
-  }
-  // If it's a vanity domain request (from the client), we can extract the owner and repository from the URL
-  // e.g. https://invertase.docs.page/melos/getting-started
-  else if (url.hostname.endsWith(".docs.page")) {
-    const chunks = url.hostname.split(".");
-    owner = chunks.at(0)!;
-    repository = path.split("/").at(0)!; // Also includes the ref if it's present
-    path = path.split("/").slice(1).join("/");
-    vanity = true;
-  }
-  // Else it's a custom domain, e.g. https://melos.invertase.dev/getting-started
-  else {
-    const domain = domains.find(([host]) => host === url.host)?.at(0);
-
-    if (!domain) {
-      throw new Error(
-        `Client host request "${url.host}" does not match a domain.`,
-      );
-    }
-
-    [owner, repository] = domain.split("/");
-  }
-
-  if (!owner || !repository) {
-    console.error("Invalid routing scenario for request", url.toString());
-    throw new Response("Invalid routing scenario for request", { status: 404 });
-  }
-
-  // Check if the repo includes a ref (invertase/foo~bar)
-  if (repository.includes("~")) {
-    [repository, ref] = repository.split("~");
-  }
-
-  // Check if the first path segment is a ref, e.g. `use.docs.page/~next`.
-  if (path.startsWith("~")) {
-    ref = path.split("/")[0].slice(1);
-    path = path.split("/").slice(1).join("/");
-  }
-
-  return { owner, repository, ref, path, vanity };
-}
-
-export type SharedEnvironmentVariables = {
-  VERCEL?: string;
-  VERCEL_ENV?: string;
-  VERCEL_GIT_COMMIT_SHA?: string;
-};
-
-declare global {
-  interface Window {
-    ENV: SharedEnvironmentVariables;
+  constructor(url: string) {
+    super();
+    this.url = url;
   }
 }
 
-// Returns the shared environment variables, from either the server or the client.
-export function getSharedEnvironmentVariables(): SharedEnvironmentVariables {
-  return typeof window === "undefined"
-    ? (process.env as SharedEnvironmentVariables)
-    : window.ENV;
-}
-
-// Returns the current environment, either `production`, `preview` or `development`.
-export function getEnvironment() {
-  const ENV = getSharedEnvironmentVariables();
-
-  return ENV.VERCEL
-    ? ENV.VERCEL_ENV === "production"
-      ? "production"
-      : "preview"
-    : "development";
-}
-
-// Returns the current build hash, either the Vercel Git commit SHA or `development`.
-export function getBuildHash() {
-  return getSharedEnvironmentVariables().VERCEL_GIT_COMMIT_SHA || "development";
-}
-
 // Helper function to merge Tailwind CSS classes with classnames.
 export function cn(...inputs: cx.ArgumentArray) {
   return twMerge(cx(inputs));
@@ -210,7 +115,7 @@ export function getHref(ctx: Context, path: string) {
   // Ensure all links start with the custom domain if it's set.
   else if (ctx.domain) {
     href += `https://${ctx.domain}`;
-  } 
+  }
   // Prefix the path with the owner and repository, e.g. `/invertase/docs.page`.
   else {
     href = `/${ctx.owner}/${ctx.repository}`;
@@ -236,22 +141,6 @@ export function getHref(ctx: Context, path: string) {
   return `${href}${normalizedPath}`;
 }
 
-// Matches a URL matching a pattern with a given path. Rules are:
-// `/foo/*` matches `/foo`, `/foo/bar`, `/foo/bar/baz/etc`
-// `/foo/:bar` matches `/foo/bar`, `/foo/baz`, etc
-// `/foo/:bar/*` matches `/foo/bar`, `/foo/bar/baz/etc`
-// `/foo/:bar/:baz` matches `/foo/bar/baz`, `/foo/bar/qux`, etc
-// `/foo/*/bar` matches `/foo/bar`, `/foo/etc/bar`, etc
-export function matchPathPattern(pattern: string, path: string) {
-  // Escape regex special characters in the pattern except for * and :
-  const regexPattern = pattern
-    .replace(/([.+?^=!:${}()|\[\]\/\\])/g, "\\$1")
-    .replace(/\*/g, ".*") // Replace * with .*
-    .replace(/\/:([^\/]+)/g, "/([^/]+)"); // Replace /:param with /([^/]+)
-
-  return new RegExp(`^${regexPattern}$`).test(path);
-}
-
 // Returns the bundle error response as a JSON response.
 export function bundleErrorResponse(bundle: BundleResponse): Response {
   if (bundle.code === "OK") {
diff --git a/website/tailwind.config.js b/website/tailwind.config.ts
similarity index 90%
rename from website/tailwind.config.js
rename to website/tailwind.config.ts
index 60e671e8..47b75899 100644
--- a/website/tailwind.config.js
+++ b/website/tailwind.config.ts
@@ -1,9 +1,9 @@
+import type { Config } from "tailwindcss";
 import defaultTheme from "tailwindcss/defaultTheme";
 import plugin from "tailwindcss/plugin";
 
-/** @type {import('tailwindcss').Config} */
-export default {
-  content: ["./app/**/*.{ts,tsx}"],
+const config: Config = {
+  content: ["./src/**/**/*.{js,ts,jsx,tsx,mdx}"],
   darkMode: ["class", '[data-theme="dark"]'],
   theme: {
     extend: {
@@ -53,3 +53,4 @@ export default {
     }),
   ],
 };
+export default config;
diff --git a/website/tsconfig.json b/website/tsconfig.json
index 45192694..6a079061 100644
--- a/website/tsconfig.json
+++ b/website/tsconfig.json
@@ -1,38 +1,23 @@
 {
-  "include": [
-    "**/*.ts",
-    "**/*.tsx",
-    "**/.server/**/*.ts",
-    "**/.server/**/*.tsx",
-    "**/.client/**/*.ts",
-    "**/.client/**/*.tsx"
-  ],
   "compilerOptions": {
-    "lib": ["DOM", "DOM.Iterable", "ES2022"],
-    "types": [
-      "@remix-run/node",
-      "vite/client",
-      "@types/wicg-file-system-access"
-      // "@remix-run/react/future/single-fetch.d.ts"
-    ],
-    "isolatedModules": true,
-    "esModuleInterop": true,
-    "jsx": "react-jsx",
-    "module": "ESNext",
-    "moduleResolution": "Bundler",
-    "resolveJsonModule": true,
-    "target": "ES2022",
-    "strict": true,
+    "target": "ES2018",
+    "lib": ["dom", "dom.iterable", "esnext"],
     "allowJs": true,
     "skipLibCheck": true,
-    "forceConsistentCasingInFileNames": true,
-    "baseUrl": ".",
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
     "paths": {
       "@docs.page/cli": ["../packages/cli/src/index.ts"],
-      "~/*": ["./app/*"]
-    },
-
-    // Vite takes care of building everything, not tsc.
-    "noEmit": true
-  }
+      "~/*": ["./src/*"]
+    }
+  },
+  "include": ["next-env.d.ts", "fsa.d.ts", "**/*.ts", "**/*.tsx"],
+  "exclude": ["node_modules"]
 }
diff --git a/website/vite.config.ts b/website/vite.config.ts
deleted file mode 100644
index 337d8c77..00000000
--- a/website/vite.config.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import path from "node:path";
-import { vitePlugin as remix } from "@remix-run/dev";
-import { installGlobals } from "@remix-run/node";
-import { vercelPreset } from "@vercel/remix/vite";
-import { defineConfig } from "vite";
-import tsconfigPaths from "vite-tsconfig-paths";
-
-installGlobals();
-
-export default defineConfig({
-  resolve: {
-    alias: {
-      // Use source files from the local workspace rather than dist files.
-      "@docs.page/cli": path.resolve(__dirname, "../packages/cli/src/index.ts"),
-    },
-  },
-  plugins: [
-    remix({
-      presets: [vercelPreset()],
-      future: {
-        unstable_singleFetch: false, // Not working on Vercel
-        v3_fetcherPersist: true,
-        v3_relativeSplatPath: true,
-        v3_throwAbortReason: true,
-      },
-    }),
-    tsconfigPaths(),
-  ],
-});