SEO & AI Engine Optimization Framework · May 2026

Remix SEO: meta exports, route data, nested layouts

A comprehensive installation and audit reference for Remix 2 and React Router 7 sites. In 2024 the Remix team announced that Remix and React Router are converging as a single project, and through…

The Web Fundamentals Framework: Loaders, Actions, Streaming, and the React Router 7 Unification

A comprehensive installation and audit reference for Remix 2 and React Router 7 sites. In 2024 the Remix team announced that Remix and React Router are converging as a single project, and through 2025 the unification continued. In 2026 the practical reality is that Remix 2 + React 19 is the production framework, while React Router 7 is the same framework rebadged. The web-fundamentals-first philosophy of Remix (real form actions, server-side data loading, progressive enhancement) maps cleanly onto SEO and AEO requirements: search engines and AI crawlers want server-rendered HTML with structured data, and Remix delivers that by default.


1. Document Purpose

This is the canonical reference for Remix as a SEO platform. Remix sits in the React ecosystem alongside Next.js as the two production-grade React meta-frameworks. The differences matter: Next.js leans toward static generation, edge rendering, and a large feature surface. Remix leans toward server-side rendering, progressive enhancement, and a smaller surface that maps more directly to the HTTP request/response cycle.

For 2026, the practical Remix landscape is:

When to recommend Remix vs alternatives:

Scenario Recommendation
Content-heavy site, SEO-first Remix or Next.js App Router
Highly dynamic forms-driven app Remix (progressive enhancement wins)
Marketing site with infrequent updates Astro or 11ty (no React needed)
Pure storefront on Shopify Hydrogen (Remix-based)
Vue ecosystem preference Nuxt instead
Svelte ecosystem preference SvelteKit instead

The 2026 Remix SEO conversation centers on five issues: getting MetaFunction right for per-route meta, injecting JSON-LD schema correctly under SSR, streaming with defer without breaking crawler parsing, canonical URL discipline across nested routes, and choosing a deployment target that does not rely on third party edge intermediaries.

1.1 Required Tools

1.2 Document Scope

Covers: the Remix 2 mental model, MetaFunction patterns, schema injection, streaming, route conventions, form actions, deployment options, internationalization, the Hydrogen relationship, migration paths, and the Bubbles self hosted deployment pattern. Does not exhaust: React component patterns (see framework-react.md), generic schema theory (see framework-schema.md), hreflang implementation specifics (see framework-hreflang.md).


2. Client Variables Intake

remix_version: ""                       # 2.x | 2.x with react-router-7 migration
react_version: ""                       # 18 | 19
build_target: ""                        # node | vercel | netlify | aws-lambda | self-hosted
deployment_target: ""                   # bubbles | vercel | netlify | aws | other
runtime: ""                             # node 20 | node 22 | deno | bun
typescript: false
i18n_in_use: false
i18n_library: ""                        # remix-i18next | inlang/paraglide-js | none
locales: []
schema_strategy: ""                     # inline-json-ld | utility-component | external-cdn
critical_seo_routes: []                 # /, /about, /products, /blog
streaming_in_use: false
forms_using_actions: false
sitemap_strategy: ""                    # static | dynamic-loader | external-sitemap
robots_txt_strategy: ""                 # static | dynamic-loader
canonical_strategy: ""                  # explicit-per-route | derived-from-url
analytics_in_use: false
analytics_strategy: ""                  # client-only | server-side-events | server-side-gtm
hydrogen_in_use: false
react_router_7_migration_planned: false

3. Remix Platform Overview 2026

3.1 The Architecture

Remix is a full stack React framework that runs on a Node.js or compatible runtime. The server renders HTML on every request by default. The client hydrates the React tree and takes over interactivity. The data flow is unidirectional and server centric: loader functions run on the server, return data, and the React component renders against that data.

The route file is the unit of Remix. Each route file exports:

This is the entire mental model. Everything else is a refinement.

3.2 The Vite Build

Remix 2 uses Vite. The dev server is fast. The production build outputs a server bundle and a client bundle. Hot module replacement works during development. The Vite plugin system gives access to the Vite ecosystem.

npx create-remix@latest my-site
cd my-site
npm install
npm run dev          # starts Vite dev server
npm run build        # produces server + client bundles
npm run start        # runs production server

3.3 The React Router 7 Unification

In 2024 the Remix team announced that React Router 7 would unify the libraries. The same loaders, actions, MetaFunction, streaming, and nested routing patterns appear under the React Router 7 name. For new projects in 2026, either Remix 2 or React Router 7 is a defensible choice; the upgrade between them is intentionally low cost.

Practical guidance:

The SEO surface is functionally identical across both. The patterns in this document apply to both.

3.4 The Progressive Enhancement Default

Remix forms work without JavaScript by default. The <Form> component submits to a server action. The page rerenders with the new state. If JavaScript loads, the client takes over and turns the form submission into a fetch, retaining the same action on the server. This pattern is the Remix signature feature and the reason SEO traffic to Remix sites tends to convert at parity with rendered JavaScript sites: even if hydration fails, the form still works.


4. Rendering Modes

4.1 Server-Side Rendering by Default

Every Remix route is server rendered on every request unless explicitly configured otherwise. The HTML response includes the rendered React tree, the route data, and the hydration script. The crawler sees fully populated HTML on the first request, which is the SEO ideal state.

This default differs from Next.js, where the developer chooses between Static, ISR, SSR, and Client per route. Remix simplifies: SSR is the default, and the optimizations to that default are streaming, cache headers, and resource preloading.

4.2 The Streaming defer Pattern

For routes where some data is slow to load (a recommendations API, a third party content feed), Remix supports streaming via defer. The loader returns the fast data immediately and a Promise for the slow data. The shell HTML streams to the client. The slow data renders in a Suspense boundary when it resolves.

import { defer } from "@remix-run/node"
import { useLoaderData, Await } from "@remix-run/react"
import { Suspense } from "react"

export async function loader() {
  const fastData = await getCriticalContent()
  const slowData = getRecommendations()  // returns Promise
  return defer({ fastData, slowData })
}

export default function Route() {
  const { fastData, slowData } = useLoaderData<typeof loader>()
  return (
    <>
      <CriticalContent data={fastData} />
      <Suspense fallback={<Skeleton />}>
        <Await resolve={slowData}>
          {(data) => <Recommendations data={data} />}
        </Await>
      </Suspense>
    </>
  )
}

The SEO consideration: crawlers do not wait for streamed content to resolve in all cases. Critical SEO content (the H1, the body copy, the primary structured data) must be in the synchronous loader data, not in the deferred Promise. Use defer for genuinely supplementary content like recommendations or recent activity feeds.

4.3 Cache Headers as ISR Substitute

Remix does not have a separate ISR primitive like Next.js. Instead, the headers function controls the HTTP cache headers, and a reverse proxy (nginx, varnish, the platform CDN) handles the cache layer.

export function headers() {
  return {
    "Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=600"
  }
}

This is functionally equivalent to ISR: the first request renders fresh; subsequent requests within the cache window serve from cache; stale-while-revalidate triggers a background regeneration. The pattern works equally well behind self hosted nginx on Bubbles or any other reverse proxy.

4.4 The No-SPA Philosophy

Remix explicitly does not support a client-only SPA mode for full pages in the recommended pattern. The framework assumes every route has a server endpoint. This is a deliberate choice that aligns with SEO best practice: a route that has no server rendering is invisible to many crawlers and to some AI extraction layers.

For app surfaces that genuinely should not be indexed (signed in dashboards, admin panels), the Remix pattern is: still server render, then add noindex meta and access control. The same code path runs for the rare case the page is crawled.

4.5 React Server Components

Remix added experimental React Server Components support during 2025 and stabilized it through React 19 alignment. The default in 2026 is still server rendered React components without RSC unless explicitly opted in. RSC adoption is appropriate when the route has heavy data dependencies that benefit from server-only rendering of large component trees; for typical content pages, plain SSR is simpler and equally fast.


5. SEO Implementation

5.1 The MetaFunction

Per route meta tags ship through the meta export. The shape:

import type { MetaFunction } from "@remix-run/node"

export const meta: MetaFunction = ({ data, params, location, matches }) => {
  return [
    { title: "Product page title" },
    { name: "description", content: "Compelling description under 160 characters." },
    { property: "og:title", content: "Product page title" },
    { property: "og:description", content: "Compelling description." },
    { property: "og:image", content: "https://example.com/og/product-123.png" },
    { property: "og:type", content: "product" },
    { tagName: "link", rel: "canonical", href: `https://example.com${location.pathname}` },
  ]
}

The function returns an array of meta descriptors. Remix merges descriptors across the route hierarchy: a root meta provides sitewide defaults, and child routes override or extend them.

5.2 Meta Inheritance Across Nested Routes

Nested routes in Remix produce nested matches. The framework calls the meta function on each matched route in order. The final document head merges the result.

// app/root.tsx
export const meta: MetaFunction = () => [
  { title: "Example Site" },
  { property: "og:site_name", content: "Example Site" },
  { name: "twitter:card", content: "summary_large_image" },
]

// app/routes/products.$id.tsx
export const meta: MetaFunction<typeof loader> = ({ data }) => [
  { title: data?.product.name + " - Example Site" },
  { name: "description", content: data?.product.metaDescription },
  { property: "og:title", content: data?.product.name },
]

The merge order: child route entries override parent entries with the same name or property. New entries append. This means the root layout provides safe defaults and individual routes refine them.

5.3 Title and Description Patterns

The title is the single most important on page SEO signal for traditional Google ranking and a strong signal for AI extraction layers. The Remix pattern:

5.4 The Canonical Tag

Every indexable route should emit an explicit canonical:

{ tagName: "link", rel: "canonical", href: `https://example.com${pathname}` }

Compute the canonical from the route data, not from location.href, because location.href includes query strings and trailing characters that should not be part of the canonical URL.

function canonicalFor(pathname: string): string {
  const base = "https://example.com"
  const clean = pathname.replace(/\/+$/, "")  // strip trailing slash
  return base + (clean || "/")
}

5.5 The og:image Pattern

The og:image URL points to a 1200 by 630 pixel PNG or JPEG. For dynamic pages (product, article, profile), generate the image on demand via a Remix resource route:

// app/routes/og.$slug.tsx
import { LoaderFunctionArgs } from "@remix-run/node"
import { generateOgImage } from "~/utils/og.server"

export async function loader({ params }: LoaderFunctionArgs) {
  const buffer = await generateOgImage(params.slug)
  return new Response(buffer, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=86400, immutable"
    }
  })
}

The generator uses satori or @vercel/og or a server-rendered HTML to PNG library. Cache aggressively. Regenerate when underlying data changes.

5.6 Resource Routes for SEO Assets

Beyond og:image, resource routes serve any non HTML endpoint:

Each resource route exports a loader that returns a Response with the correct content type.

// app/routes/sitemap[.]xml.ts
import { LoaderFunctionArgs } from "@remix-run/node"
import { getAllRoutes } from "~/utils/sitemap.server"

export async function loader({ request }: LoaderFunctionArgs) {
  const routes = await getAllRoutes()
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes.map(r => `  <url>
    <loc>${r.url}</loc>
    <lastmod>${r.lastModified}</lastmod>
    <changefreq>${r.changeFreq}</changefreq>
    <priority>${r.priority}</priority>
  </url>`).join("\n")}
</urlset>`
  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "public, max-age=3600"
    }
  })
}

6. Schema Implementation

6.1 JSON-LD via MetaFunction

The simplest pattern: emit JSON-LD as a script tag through the meta function using the tagName: "script" descriptor.

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) return []
  const schema = {
    "@context": "https://schema.org",
    "@type": "Product",
    "@id": `https://example.com/products/${data.product.id}#product`,
    name: data.product.name,
    description: data.product.description,
    image: data.product.image,
    offers: {
      "@type": "Offer",
      price: data.product.price,
      priceCurrency: "USD",
      availability: data.product.inStock
        ? "https://schema.org/InStock"
        : "https://schema.org/OutOfStock"
    }
  }
  return [
    { title: data.product.name },
    {
      tagName: "script",
      type: "application/ld+json",
      children: JSON.stringify(schema)
    }
  ]
}

6.2 The @id Graph Pattern Across Routes

For sitewide entities (Organization, WebSite, Person founder), inject the schema at the root layout. For per page entities (Product, Article, Event), inject at the route layer with @id cross references to the root entities.

// app/root.tsx meta
{
  tagName: "script",
  type: "application/ld+json",
  children: JSON.stringify({
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "Organization",
        "@id": "https://example.com/#organization",
        name: "Example Co",
        url: "https://example.com/",
        logo: "https://example.com/logo.png",
        sameAs: [
          "https://www.linkedin.com/company/example",
          "https://www.wikidata.org/wiki/Q12345"
        ]
      },
      {
        "@type": "WebSite",
        "@id": "https://example.com/#website",
        url: "https://example.com/",
        name: "Example",
        publisher: { "@id": "https://example.com/#organization" }
      }
    ]
  })
}

// app/routes/articles.$slug.tsx meta
{
  tagName: "script",
  type: "application/ld+json",
  children: JSON.stringify({
    "@context": "https://schema.org",
    "@type": "Article",
    "@id": `https://example.com/articles/${data.slug}#article`,
    headline: data.headline,
    datePublished: data.publishedISO,
    dateModified: data.modifiedISO,
    author: {
      "@type": "Person",
      name: data.authorName,
      sameAs: data.authorLinkedIn
    },
    publisher: { "@id": "https://example.com/#organization" }
  })
}

The crawler reads the document head, extracts both script blocks, and reconstructs the entity graph via the @id references.

6.3 The Schema Helper Utility

For larger sites, abstract schema generation into helper functions:

// app/utils/schema.server.ts
export function articleSchema(article: Article) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    "@id": `https://example.com/articles/${article.slug}#article`,
    headline: article.headline,
    description: article.description,
    image: article.image,
    datePublished: article.publishedAt.toISOString(),
    dateModified: article.updatedAt.toISOString(),
    author: {
      "@type": "Person",
      name: article.author.name,
      url: `https://example.com/authors/${article.author.slug}`
    },
    publisher: { "@id": "https://example.com/#organization" }
  }
}

// in route meta
import { articleSchema } from "~/utils/schema.server"
export const meta: MetaFunction<typeof loader> = ({ data }) => [
  // ...
  {
    tagName: "script",
    type: "application/ld+json",
    children: JSON.stringify(articleSchema(data.article))
  }
]

Cross reference framework-schema.md for type level guidance on Article, Product, LocalBusiness, FAQPage, HowTo, Event, BreadcrumbList, Person, Organization.

6.4 The Common Schema Mistakes

Validate every published page via the Google Rich Results Test plus the Schema.org Validator before declaring a route shipped.


7. Performance Profile

7.1 The Web Fundamentals Baseline

Remix's design philosophy of parallel loaders, server-side rendering, and minimal client JavaScript produces consistently high Lighthouse scores. A well structured Remix site typically achieves:

The framework does not stand in the way of performance; common regressions come from third party scripts (analytics, chat widgets, tag managers) and from images and fonts not preloaded.

7.2 Parallel Loaders

Nested routes have parallel loaders. If a layout route has a loader and the leaf route has a loader, both run simultaneously, not sequentially. This is the Remix performance trick that distinguishes it from frameworks where data fetching is sequential.

Request /products/widget-123
- app/root.tsx loader runs in parallel with
- app/routes/products.tsx loader runs in parallel with
- app/routes/products.$id.tsx loader

All three complete before the render begins.
Total request time = max(loader1, loader2, loader3), not sum.

This parallel data fetching shows up in real world Lighthouse scores as faster Time to First Byte and faster Largest Contentful Paint.

7.3 The Streaming Performance Pattern

For routes where one loader is fast and another is slow, the defer pattern (Section 4.2) ships the shell HTML quickly and streams the slow data when ready. The user sees the page render in two phases instead of waiting for the slowest loader.

7.4 Image Optimization

Remix does not ship an image optimization primitive in core. The pattern is:

<img
  src="/_img/products/widget-123.jpg?w=800"
  srcset="/_img/products/widget-123.jpg?w=400 400w,
          /_img/products/widget-123.jpg?w=800 800w,
          /_img/products/widget-123.jpg?w=1200 1200w"
  sizes="(min-width: 768px) 800px, 100vw"
  alt="Widget 123"
  width="800"
  height="600"
  loading="lazy"
  decoding="async"
/>

Cross reference framework-imageseo.md for the full image SEO doctrine.

7.5 Resource Hints

Use the links function on the route or root to declare resource hints:

export const links: LinksFunction = () => [
  { rel: "preconnect", href: "https://fonts.example.com" },
  { rel: "dns-prefetch", href: "https://api.example.com" },
  { rel: "preload", as: "font", type: "font/woff2", href: "/fonts/primary.woff2", crossOrigin: "anonymous" }
]

7.6 The React 19 Server Components Opt-In

When opted in, React Server Components reduce client side JavaScript by rendering more of the tree on the server. Adoption is appropriate for content heavy routes; for interactive routes the standard SSR + hydration pattern is simpler and equally fast in practice.


8. URL Structure and Routing

8.1 File Based Routing

Remix 2 supports both the flat route convention and the nested folder convention. The flat convention uses . separators in the filename to express nesting:

app/routes/
  _index.tsx                            -> /
  products._index.tsx                   -> /products
  products.$id.tsx                      -> /products/:id
  products.$id._index.tsx               -> /products/:id (alt)
  products.$id.reviews.tsx              -> /products/:id/reviews
  blog.tsx                              -> /blog (layout)
  blog._index.tsx                       -> /blog
  blog.$slug.tsx                        -> /blog/:slug
  $.tsx                                 -> catch all (404)

8.2 Dynamic Segments

The $param syntax captures a single segment. The $ catch all captures the rest. In the loader:

export async function loader({ params }: LoaderFunctionArgs) {
  const slug = params.slug
  const article = await getArticleBySlug(slug)
  if (!article) throw new Response("Not Found", { status: 404 })
  return json({ article })
}

The throw new Response("Not Found", { status: 404 }) is the Remix idiom for 404. The CatchBoundary or ErrorBoundary renders the 404 page. The HTTP status is correctly set, which matters for SEO: a soft 404 (a 200 response that shows a not found message) confuses crawlers and dilutes domain trust.

8.3 The Trailing Slash Decision

Remix does not enforce a trailing slash policy. The site author chooses. The two valid policies:

Pick one and stick to it. Configure the reverse proxy (nginx) to 301 redirect the alternative form to the canonical form. The canonical tag in the document head reinforces the choice.

# nginx: redirect trailing slash to non trailing slash
rewrite ^(.+)/$ $1 permanent;

8.4 Layout Routes

A folder name without _index is a layout route. Children render inside the layout's <Outlet />. The layout's loader runs in parallel with the child's loader. This is the Remix pattern for shared layout data:

// app/routes/account.tsx (layout)
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request)
  return json({ user })
}

export default function AccountLayout() {
  const { user } = useLoaderData<typeof loader>()
  return (
    <>
      <AccountNav user={user} />
      <main><Outlet /></main>
    </>
  )
}

// app/routes/account.profile.tsx
export default function Profile() {
  // useRouteLoaderData to access parent loader data
  return <h1>Profile</h1>
}

8.5 Resource Routes

A route that does not export a default component is a resource route. It serves a non HTML response. Sitemap, robots, og image, feed, security.txt all live as resource routes. Pattern in Section 5.6.


9. Forms and Mutations

9.1 The Progressive Enhancement Form

Remix's <Form> component is the framework's signature feature:

import { Form } from "@remix-run/react"

export default function ContactPage() {
  return (
    <Form method="post" action="/contact">
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </Form>
  )
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const result = await sendContactEmail({
    name: formData.get("name") as string,
    email: formData.get("email") as string,
    message: formData.get("message") as string
  })
  if (!result.success) {
    return json({ error: result.error }, { status: 400 })
  }
  return redirect("/contact/thanks")
}

Without JavaScript: form submits via standard browser form POST. The server runs the action. The browser navigates to the redirect target.

With JavaScript: Remix intercepts the submit, sends a fetch, runs the same action, and updates the page without a full reload.

The SEO consequence: forms work for users on slow connections, users on locked down corporate browsers, and crawlers that submit forms during indexing. The conversion rate impact is measurable on real audits, often 3 to 8 percent uplift vs pure JavaScript forms.

9.2 The useFetcher Pattern

For inline mutations that should not navigate the page (like, favorite, add to cart), use useFetcher:

import { useFetcher } from "@remix-run/react"

export default function ProductCard({ product }: { product: Product }) {
  const fetcher = useFetcher()
  const isAdding = fetcher.state === "submitting"
  return (
    <fetcher.Form method="post" action="/cart/add">
      <input type="hidden" name="productId" value={product.id} />
      <button type="submit" disabled={isAdding}>
        {isAdding ? "Adding..." : "Add to cart"}
      </button>
    </fetcher.Form>
  )
}

9.3 Action Validation

Validate inputs in the action with a schema library (Zod, Valibot, ArkType):

import { z } from "zod"

const ContactSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  message: z.string().min(10).max(5000)
})

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const parsed = ContactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message")
  })
  if (!parsed.success) {
    return json({ errors: parsed.error.flatten().fieldErrors }, { status: 400 })
  }
  // proceed with valid data
}

9.4 Cross Reference

The full form SEO doctrine including honeypots, autocomplete attributes, error handling, and accessibility is in framework-formoptimization.md. The forms in Remix benefit from progressive enhancement by default but still need the accessibility and CRO polish covered in that document.


10. Deployment Targets

10.1 The Adapter Pattern

Remix abstracts the deployment target behind an adapter:

The application code is portable across adapters. The adapter handles the request shape conversion between the platform and Remix's internal Web Request/Response API.

10.2 Self Hosted Node.js (Bubbles Pattern)

For Joseph's stack the default deployment target is self hosted Node.js on Bubbles with PM2 process management and nginx as the reverse proxy. Section 14 documents this in detail. The summary:

npm install @remix-run/node @remix-run/serve
npm run build
pm2 start npm --name remix-app -- run start

10.3 Vercel

Vercel is the default Next.js platform and provides first class support for Remix via @vercel/remix. The deployment is git push triggered. Edge functions are an option (with the caveat that the Bubbles preferred stack avoids third party edge intermediaries entirely; for clients comfortable with platform hosting, Vercel is fine).

10.4 Netlify

Netlify supports Remix via @netlify/remix-adapter with Netlify Functions handling SSR. Netlify Edge Functions are also supported. Same caveat as Vercel for the Bubbles preferred stack.

10.5 AWS Lambda

For enterprises already on AWS, Remix runs on Lambda via @remix-run/architect or directly via Lambda handler shims. Cold start latency is the typical concern; for sites with sustained traffic this is rarely visible.

10.6 The No-Edge-Intermediary Policy

For Joseph's preferred deployment pattern, no third party CDN, no third party edge runtime, no third party DNS based geo load balancing. The site serves from nginx on Bubbles at the published IP 169.155.162.118. The trade off is higher latency for users far from the Arkansas origin; the justifications include operational sovereignty, cost discipline, and full request path control.


11. Hydrogen as a Remix-based Framework

11.1 The Hydrogen Architecture

Shopify Hydrogen 2 is built on Remix. The Storefront API integration, the React Server Components opt in, and the Oxygen edge runtime are all Hydrogen specific. The underlying patterns (loaders, actions, MetaFunction, streaming) are pure Remix.

For comprehensive Hydrogen coverage see framework-hydrogen.md. This section covers what Remix developers should know if they encounter a Hydrogen project or are considering Hydrogen for a Shopify merchant.

11.2 The React Router 7 Future

As Remix unifies under React Router 7, Hydrogen's underlying framework name will shift. The patterns stay the same. The migration is incremental. The SEO surface is identical.

11.3 When to Choose Hydrogen vs Plain Remix on Shopify

Plain Remix can talk to Shopify's Storefront API directly without Hydrogen. The reasons to use Hydrogen on a Shopify merchant project:

The reasons to use plain Remix on a Shopify project:

Either is defensible. For most Shopify merchants moving beyond native theme limits, Hydrogen is the lower friction path.


12. Internationalization

12.1 The Strategy Choice

Remix does not ship internationalization in core. The two common patterns:

Both work. The choice depends on team preference and existing i18n infrastructure.

12.2 The Locale Routing Pattern

The recommended URL pattern: /<locale>/<path> subpath routing. The locale is the first URL segment. This makes hreflang generation straightforward and lets the same Remix route handle all locales with the locale as a URL parameter.

app/routes/
  $lang.tsx                       (locale layout)
  $lang._index.tsx                (locale home)
  $lang.products.tsx
  $lang.products.$id.tsx

The $lang layout extracts the locale from params.lang, validates it against the supported list, and provides it to children via React context or useRouteLoaderData.

12.3 The Hreflang Generation

Generate hreflang link tags in the meta function of the locale layout:

export const meta: MetaFunction<typeof loader> = ({ data, matches, location }) => {
  const supportedLocales = ["en", "es", "fr", "de"]
  const pathWithoutLocale = location.pathname.replace(/^\/[a-z]{2}/, "")
  const hreflangs = supportedLocales.flatMap(locale => [
    {
      tagName: "link",
      rel: "alternate",
      hrefLang: locale,
      href: `https://example.com/${locale}${pathWithoutLocale}`
    }
  ])
  return [
    ...hreflangs,
    {
      tagName: "link",
      rel: "alternate",
      hrefLang: "x-default",
      href: `https://example.com/en${pathWithoutLocale}`
    }
  ]
}

Cross reference framework-hreflang.md for the full hreflang doctrine including the ISO code lists, the validation patterns, and the sitemap method as an alternative.

12.4 Cross Reference

Full international SEO strategy beyond hreflang in framework-international.md: market selection, domain architecture (ccTLD vs subdomain vs subdirectory), content localization vs translation, regional search engines.


13. Migration to and from Remix

13.1 Remix 1 to Remix 2

The Remix 1 to Remix 2 migration was the V2 routing convention migration plus the Vite switch. For sites still on Remix 1 in 2026, migration to Remix 2 is recommended:

The Remix team maintains a codemod for the routing convention change.

13.2 Remix 2 to React Router 7

The Remix 2 to React Router 7 migration is the API rename plus adapter switch. The framework primitives are unchanged. For sites on Remix 2 in 2026 the migration is optional; the upgrade window is open.

13.3 Next.js to Remix

Common reasons to migrate from Next.js to Remix:

The migration is route by route. Set up Remix alongside the Next.js site, route a subset of paths to Remix via nginx, expand. The schema and meta tag work translates directly; the data fetching translates from getServerSideProps or App Router async components to loader.

Cross reference framework-migration.md for migration discipline including URL mapping, redirect plans, pre launch validation, and AI surface continuity.

13.4 SvelteKit to Remix

Possible but unusual. The SvelteKit +page.server.ts load function maps to Remix loader. The form actions map directly. The template translation from Svelte to React is the bulk of the work.

13.5 React Router 6 to React Router 7 Framework Mode

For SPA sites on React Router 6, upgrading to React Router 7 framework mode (Remix style) is the path to add SSR. The migration is incremental: keep the SPA shell working, add server rendering to critical SEO routes one at a time.


14. Bubbles-Hosted Remix

14.1 The Stack

Self hosted on Debian 12 nginx at the public IP 169.155.162.118. The Remix application runs as a Node.js process under PM2. Nginx reverse proxies HTTPS requests to the local Node process.

Internet → 169.155.162.118:443 → nginx (TLS termination, HTTP/3)
        → 127.0.0.1:3000        → PM2 → node server.js → Remix

14.2 The Server Runtime

Node.js 20 LTS is the baseline. Node.js 22 is acceptable. The runtime ships in the Debian apt repository or via nvm. The team standard is nvm-managed Node so multiple Node versions can coexist for different projects.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
nvm install 20
nvm use 20
node --version

14.3 The Project Layout

/var/www/sites/example.com/
  remix-app/
    app/                            (Remix app code)
    public/                         (static assets)
    server.js                       (custom server entry)
    package.json
    vite.config.ts
    tsconfig.json
    build/                          (output: server bundle)
    public/build/                   (output: client bundle)
    .env                            (production env vars)
    ecosystem.config.cjs            (PM2 config)

14.4 The Server Entry

A custom server entry uses @remix-run/express to integrate with Express, or @remix-run/serve for the built-in server. Production sites typically use Express for extra middleware:

// server.js
import { createRequestHandler } from "@remix-run/express"
import compression from "compression"
import express from "express"
import morgan from "morgan"

const app = express()
app.use(compression())
app.use(morgan("tiny"))
app.use(express.static("public", { maxAge: "1h" }))
app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" }))

app.all("*", createRequestHandler({
  build: await import("./build/index.js"),
  mode: process.env.NODE_ENV
}))

const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Listening on ${port}`))

14.5 PM2 Configuration

// ecosystem.config.cjs
module.exports = {
  apps: [{
    name: "remix-example",
    script: "./server.js",
    instances: "max",          // cluster mode, one process per CPU core
    exec_mode: "cluster",
    env_production: {
      NODE_ENV: "production",
      PORT: 3000
    },
    error_file: "/var/log/pm2/remix-example.err.log",
    out_file: "/var/log/pm2/remix-example.out.log",
    max_memory_restart: "1G"
  }]
}
pm2 start ecosystem.config.cjs --env production
pm2 save
pm2 startup systemd

14.6 The Nginx Config

# /etc/nginx/sites-available/example.com
server {
  listen 80;
  listen [::]:80;
  server_name example.com www.example.com;
  return 301 https://example.com$request_uri;
}

server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  listen 443 quic reuseport;
  listen [::]:443 quic reuseport;
  http3 on;
  add_header Alt-Svc 'h3=":443"; ma=86400';

  server_name example.com;

  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;

  # Security headers
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;

  # Static assets directly from disk
  location /build/ {
    alias /var/www/sites/example.com/remix-app/public/build/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering on;
    proxy_cache_bypass $http_upgrade;
  }
}
ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

14.7 The Deploy Script

#!/usr/bin/env bash
# /home/user/scripts/deploy-remix-example.sh
set -euo pipefail
cd /var/www/sites/example.com/remix-app
git pull origin main
nvm use 20
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.cjs --env production
echo "Deploy complete at $(date -u +%FT%TZ)"

14.8 The Staging Production Split

For sites with active development, a staging environment is essential:

14.9 Monitoring and Logs

pm2 logs remix-example          # tail logs
pm2 monit                       # live dashboard
pm2 status                      # process status
journalctl -u nginx -f          # nginx logs

Combine with a self hosted observability stack (Prometheus + Grafana, or Loki + Grafana) for production grade monitoring. The Bubbles host runs the full stack alongside the Remix app.

14.10 The Backup Pattern

Daily backups of /var/www/sites/ to the 4.5TB external storage at /mnt/storage/. Weekly off site backup to Backblaze B2 for clients requiring data residency redundancy. The database (if any) backs up separately via its own dump cycle.

14.11 The No Third Party Stance

The stack uses no third party CDN, no third party proxy, no edge intermediary. The reasons: operational sovereignty so the team controls the full request path, cost discipline so the agency does not rebill platform per-request fees to small clients, simplicity so there are fewer moving parts to debug, and vendor independence so a third party outage does not affect client delivery. The trade off: Bubbles is the single point of failure. Mitigation: the cold standby pattern where the framework can be redeployed to a new Debian server within 4 hours given the backup tarballs.


End of Framework Document

Companion documents:

Want this framework implemented on your site?

ThatDevPro ships these frameworks as productized services. SDVOSB-certified veteran owned. Cassville, Missouri.

See Engine Optimization service ›