Why Remix
The YP site is a public business directory. Search engines must crawl it, users must see content instantly, and the site must work without JavaScript. A client-side SPA fails all three requirements. Remix v2 on Cloudflare Workers solves this:
- Server-side rendering — every page request returns complete HTML from the Worker edge, not an empty shell that waits for JS
- Loaders — each route exports a
loader()function that runs on the server, queries D1/Vectorize/AI, and returns data before the page renders - Actions — form submissions are handled by server-side
action()functions with progressive enhancement - No API layer — loaders access Cloudflare bindings (D1, AI, Vectorize, service bindings) directly, no
/api/*routes needed for page data
How it works on Cloudflare
Browser request
-> Cloudflare Worker (worker.ts)
-> Remix server bundle (build/server/index.js)
-> Route matching
-> loader() runs, queries D1
-> React component renders to HTML stream
<- Full HTML response (with hydration script)
The vite.config.ts uses the Remix Vite plugin which produces two outputs:
- build/server/ — the server bundle deployed as a Cloudflare Worker
- build/client/ — static JS, CSS, and assets served via the Worker's asset binding
The wrangler.toml points main to worker.ts (which loads the Remix server bundle) and sets [assets] directory = "build/client".
Route structure
Routes live in app/routes/. The file name determines the URL:
_index.tsx— the homepage and search page at/business.$slug.$id.tsx— business detail at/business/some-slug/123design-system.tsx— component showcase at/design-systemadmin.tsx,admin.components.tsx,admin.design.tsx— admin pagesabout-us.tsx,faq.tsx,contact.tsx— static info pages
Each route can export:
loader()— server-side data fetching (runs before render)action()— server-side form handling (runs on POST)meta()— page title, description, Open Graph tagsdefault— the React component that renders the page
Loaders and D1
A loader receives the Cloudflare context and can query D1 directly:
export async function loader({ request, context }: LoaderFunctionArgs) {
const db = context.cloudflare.env.DB;
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const results = await db.prepare('SELECT * FROM businesses WHERE name LIKE ?')
.bind(`%${query}%`).all();
return { results: results.results, query };
}
The component receives this data via useLoaderData():
export default function SearchPage() {
const { results, query } = useLoaderData<typeof loader>();
return <div>...</div>;
}
Progressive enhancement
Remix Form works like a regular HTML form — it submits without JavaScript. When JS loads, Remix intercepts the submission and does it via fetch for a smoother experience. This means:
- Search works even if JS fails to load
- Filter changes update the URL (shareable, bookmarkable)
- Crawlers see the full search results in the HTML response
Layout
The root layout lives in app/root.tsx. It provides the navigation bar, footer, global styles, and Remix plumbing (Meta, Links, Scripts, ScrollRestoration, Outlet). Individual routes render inside the Outlet.