SEO & AI Engine Optimization Framework · May 2026

React (SPA) SEO: hydration, dynamic rendering, the bot-vs-user problem

Installation and audit reference for React as it exists in 2026. React 19 went stable late 2024. By 2026 the conversation has shifted away from Create React App SPAs and toward the meta framework.…

The 2026 Canonical Reference for React the Library, React Server Components, the App Router Era, and the SEO Surface Across CSR, SSR, and RSC Modes

Installation and audit reference for React as it exists in 2026. React 19 went stable late 2024. By 2026 the conversation has shifted away from Create React App SPAs and toward the meta framework. The React Server Components paradigm, the Next.js App Router as the dominant production pattern, and the slow death of pure client-side rendering for content sites define the landscape.

Cross-stack note: this is the canonical React reference. Companions at End of Framework cover Next.js, Remix, Hydrogen, schema, Core Web Vitals, Tailwind, forms, accessibility, AI citations.


1. Document Purpose

React in 2026 stabilized in version 19 with first-class Server Components, the use hook, Actions, and a hardened concurrent renderer. The framework conversation has narrowed: Next.js App Router dominates content sites, Remix and React Router 7 hold the web-fundamentals camp, Vite plus client-only React remains the default for dashboards, and Astro plus React islands handles marketing surfaces.

The SEO implication: React itself does not own the rendering decision; the framework wrapping React does. A React app built with Next.js App Router ships server-rendered HTML by default, includes structured data in the initial response, and emits proper canonical tags. The same components inside a Vite SPA ship a near-empty HTML shell and depend on Googlebot executing JavaScript. Identical code, orders-of-magnitude different SEO outcomes.

This framework treats rendering mode as the primary SEO axis. Every section below answers: what HTML does the user agent receive at first byte. That determines visibility in Bing, ChatGPT search, Perplexity, Slack previews, Lighthouse, and the long tail of crawlers that never execute JavaScript.

1.1 Required Tools

Node.js 20 or 22 LTS, pnpm or npm 10+, TypeScript 5.4+, Vite 6 or Next.js 15+, React 19, a meta framework if SEO matters (Next.js, Remix, or Astro), Lighthouse CI, Chrome DevTools Performance panel, React Developer Tools extension, Google Search Console, Google Rich Results Test, Schema.org Validator, the curl command line for raw HTML inspection.

1.2 Document Scope

Covers React 19 rendering modes, Server Components mental model, SEO across CSR/SSR/RSC, Next.js App Router, legacy Pages Router, React 19 hooks, schema injection, hydration, Server Actions, state management, migration, and the Bubbles self-hosted deployment pattern. Does not exhaust: see companions at End of Framework for Next.js, Remix, Hydrogen, schema, hreflang, accessibility specifics.

1.3 Operating Modes

Mode A, Install. New React project. Pick the framework first, then follow Sections 3 through 14 in order.

Mode B, Audit. Existing React project. Identify the rendering mode in Section 3, walk the diagnostic patterns, address gaps.

Mode C, Migrate. Existing project on a different rendering mode, an older React version, or a different framework. Walk Section 13 in order.


2. Client Variables Intake

Stored at /var/www/sites/[domain]/audit/react/intake.yaml.

# REACT SEO CLIENT VARIABLES 2026
business_name: ""
primary_domain: ""
launch_date: ""
project_phase: ""                             # design, build, launch, audit

# Framework
react_version: ""                             # 18.3 | 19.0 | 19.1
framework: ""                                 # next-app | next-pages | remix | rr7 | vite-spa | astro-react
framework_version: ""
typescript_used: true
node_version: ""

# Rendering
rendering_mode: ""                            # csr_spa | ssr_pages | ssr_app | rsc | ssg | isr | hybrid
server_components_used: false
streaming_ssr_used: false
client_components_count: 0
server_components_count: 0

# Routing and build
router_library: ""                            # next-app-router | next-pages-router | react-router-7 | remix
routes_total: 0
dynamic_routes_total: 0
bundler: ""                                   # vite | webpack | turbopack | rspack
javascript_bytes_initial_p75: 0
javascript_bytes_total_p75: 0

# Meta, schema, state, forms
meta_strategy: ""                             # next-metadata | next-head | react-helmet-async | unhead | manual
canonical_strategy: ""
og_image_strategy: ""
schema_strategy: ""                           # json-ld-component | schema-dts-typed | manual | missing
schema_graph_pattern_used: false
schema_types_present: []
state_management: ""                          # tanstack-query | zustand | redux-toolkit | jotai | context-only
form_strategy: ""                             # server-actions | client-fetch | react-hook-form
server_actions_used: false

# Hosting
hosting_target: ""                            # vercel | netlify | self-hosted-node | bubbles | aws
node_process_manager: ""                      # pm2 | systemd | docker
reverse_proxy: ""                             # nginx | caddy | apache

# Performance baseline
lcp_mobile_p75: 0
inp_mobile_p75: 0
cls_mobile_p75: 0
ttfb_mobile_p75: 0
lighthouse_mobile_perf: 0
lighthouse_mobile_seo: 0

# International and migration
international_active: false
i18n_library: ""                              # next-intl | next-i18next | react-intl | none
locales: []
locale_routing: ""                            # subpath | subdomain | domain | none
previous_react_version: ""
previous_framework: ""
migration_in_flight: false
target_framework: ""

3. React Rendering Modes 2026

The rendering mode determines the SEO outcome. The component code is largely identical across modes. Below are the seven flavors in active production use in 2026, ordered from the worst SEO outcome to the best.

3.1 Pure CSR Single Page Application

The classic Create React App pattern, now built with Vite or Rsbuild. The HTML response is a near empty shell. React mounts to a root div, fetches data, renders the tree, and updates the DOM. Title and meta tags get injected by react-helmet-async after JavaScript executes.

The SEO surface: Googlebot will render the page in a second pass and index the output most of the time. Bingbot generally does not render JavaScript well enough for content sites. AI crawlers (GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User) do not execute JavaScript at all. Social previewers (Facebookexternalhit, Slackbot, Twitterbot, LinkedInBot) read the initial HTML only. Lighthouse SEO scores reflect what is in the shell.

Verdict for 2026: pure CSR for any public content surface is malpractice. The only acceptable use cases are dashboards behind authentication, internal tools, and admin panels.

3.2 SSR via Next.js Pages Router

The legacy Next.js pattern using the pages/ directory and getStaticProps, getServerSideProps, or getInitialProps for data fetching. HTML is rendered on the server (or at build time for SSG), shipped to the browser, and hydrated by React. The <Head> component from next/head injects meta tags into the document head during SSR.

The SEO surface: excellent. The HTML response contains the full rendered output, meta tags, canonical, structured data. Search engines index what they receive. Hydration is the only client side step, and React 18+ handles hydration well as long as the server and client render the same tree.

The verdict for 2026: still production grade. New projects should start on App Router but existing Pages Router applications do not need to migrate purely for SEO reasons.

3.3 SSR Plus RSC via Next.js App Router

The dominant React production pattern in 2026. The app/ directory replaces pages/. Each route gets a page.tsx and an optional layout.tsx. Server Components are the default. Client Components require an explicit 'use client' directive. Data fetching happens inside async server components using fetch with extended caching semantics.

The SEO surface: best in class for any React application. HTML at first byte includes everything that runs on the server, which is most of the page. JavaScript bytes shipped to the client are dramatically smaller because Server Components never serialize to the client bundle. Metadata is exported from each route file as a static object or a generateMetadata function. Open Graph images are generated dynamically via opengraph-image.tsx files.

The verdict for 2026: this is the default recommendation for any new React project where SEO matters. Section 6 covers the patterns in depth.

3.4 SSR via Remix or React Router 7

Remix 2 and React Router 7 are essentially the same framework. The mental model is loaders, actions, and route components. Data fetching happens in server side loader functions. The framework streams HTML by default. Meta tags come from a meta export on each route module.

The SEO surface: excellent. Remix takes a stricter web fundamentals approach than Next.js: form actions, real HTTP semantics, progressive enhancement. Server Components are not part of the Remix model in the same way they are in Next.js App Router; Remix renders React on the server using traditional SSR. The HTML output is comparable in quality to Next.js App Router output.

The verdict for 2026: Remix and React Router 7 are excellent choices when the application is heavily form driven. See framework-remix.md for the deep dive.

3.5 React Server Components Only Architectures

A small but growing pattern: applications that use Server Components without Next.js. The Waku framework and a handful of experimental setups expose RSC primitives directly. Not recommended for production SEO sensitive applications. Use Next.js App Router instead.

3.6 Astro Plus React Islands

Astro renders pages to static HTML by default and lets you opt React components into client side hydration with client:load, client:idle, client:visible, or client:only directives. The rest of the page stays HTML.

The SEO surface: best in class for content sites. The HTML is static at first byte. Interactivity is opt in per component. JavaScript shipped to the client is minimal. Schema, meta, canonical, hreflang all live in HTML at first byte. Use Astro for content sites, marketing pages, blogs, documentation. Use Next.js App Router when most of the page is dynamic.

3.7 SvelteKit Comparison

Not React, but worth referencing because the architectural decisions match Next.js App Router closely. SvelteKit renders on the server, hydrates on the client, and supports SSG and SSR per route. The Svelte component model is simpler than React Server Components but the SEO outcome is comparable. The framework choice rarely matters for SEO once the rendering mode is correct.


4. React Server Components Deep Dive

React Server Components (RSC) is the headline React 19 feature for SEO. Released as stable in late 2024 and adopted widely through 2025, by 2026 RSC is the production pattern inside Next.js App Router. The mental model differs enough from traditional SSR that it deserves a dedicated section.

4.1 The Boundary Semantics

Every React component in an RSC enabled application is either a Server Component or a Client Component. The default is Server Component. The 'use client' directive at the top of a file marks the file as a Client Component module, and everything imported from it transitively gets included in the client bundle.

// app/blog/[slug]/page.tsx is a Server Component by default
import { getPost } from '@/lib/posts';
import { CommentForm } from './CommentForm';

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
      <CommentForm postId={post.id} />
    </article>
  );
}

// app/blog/[slug]/CommentForm.tsx is a Client Component
'use client';
import { useState } from 'react';

export function CommentForm({ postId }: { postId: string }) {
  const [text, setText] = useState('');
  return <form><textarea value={text} onChange={(e) => setText(e.target.value)} /><button>Post</button></form>;
}

The Server Component runs only on the server. It can use Node.js APIs, database clients, environment variables marked server only. Its rendered output gets serialized into the HTML response. The Client Component file ships to the browser, where React hydrates it and the useState hook works.

4.2 The Streaming Pattern

RSC pairs naturally with React Suspense and HTML streaming. Slow data fetching gets wrapped in Suspense boundaries. The shell streams immediately. The slow content fills in when it resolves.

import { Suspense } from 'react';

export default async function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading...</p>}><RecentActivity /></Suspense>
      <Suspense fallback={<p>Loading...</p>}><Metrics /></Suspense>
    </div>
  );
}

The SEO consideration: streaming SSR sends partial HTML before all data resolves. Googlebot understands streamed HTML and indexes the eventual full output. Slow crawlers that abort the connection early may index only the shell. In practice this rarely matters for content sites because the critical SEO content (title, meta, schema, primary headings, body copy) belongs above any Suspense boundary, not inside it.

4.3 Data Fetching at the Component Level

Server Components let you fetch data inline rather than orchestrating through a top level getServerSideProps. Each component fetches what it needs. The fetch cache deduplicates identical requests within a single render.

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const [product, related] = await Promise.all([
    fetch(`https://api.example.com/products/${slug}`, { next: { revalidate: 3600 } }).then(r => r.json()),
    fetch(`https://api.example.com/products/${slug}/related`, { next: { revalidate: 3600 } }).then(r => r.json()),
  ]);
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <RelatedProducts items={related} />
    </main>
  );
}

The SEO benefit: all data fetching happens server side. The HTML response contains every product field, every related reference, every piece of structured content. Nothing waits for client side fetch.

4.4 Implications for SEO

The shift from traditional SSR to RSC has three SEO implications. More pre rendered HTML: Server Components mean more of the page renders on the server. The HTML payload contains more indexable content than a comparable client heavy SPA. Less JavaScript shipped to the client: Server Component code never lives in the client bundle. The JavaScript footprint drops, sometimes dramatically. Smaller bundles mean faster LCP, faster INP, better Core Web Vitals. Cleaner separation between content and interactivity: the mental model encourages keeping content in Server Components and pulling interactive widgets into Client Components only when needed. The risk: misuse. Teams sometimes mark too many components as Client Components reflexively, defeating the RSC benefit. Section 10 covers the hydration boundary in detail.


5. SEO Implementation in Pure SPAs

For projects stuck on pure client side rendering, this section covers the survival patterns. The premise: pure CSR React for any public content surface is malpractice in 2026. The mitigations buy partial visibility at significant cost. The right answer is to migrate to a framework. The patterns below exist for projects that cannot migrate this quarter.

5.1 The Pure CSR Reality

A typical Vite React SPA build produces an index.html with a charset meta, a generic <title>App</title>, the bundled CSS link, the module script tag, and <body><div id="root"></div></body>. That HTML is what every non Google crawler sees. Title is generic. No description. No canonical. No Open Graph. No schema.

When you run curl example.com against a CSR site, you get this shell. When Bingbot crawls, it gets this shell. When ChatGPT-User fetches a URL the user pasted into Claude or ChatGPT, it gets this shell. The site is functionally invisible outside Google.

5.2 react-helmet-async for Client Side Meta

The standard React library for injecting meta tags. It updates document head after JavaScript runs, which helps in two cases: browsers (the tab title and bookmark behavior work) and Googlebot (the second pass render captures the updated head).

import { HelmetProvider, Helmet } from 'react-helmet-async';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <HelmetProvider><App /></HelmetProvider>,
);

export function ProductPage({ product }: { product: Product }) {
  return (
    <>
      <Helmet>
        <title>{product.name} | Brand</title>
        <meta name="description" content={product.description} />
        <link rel="canonical" href={`https://example.com/products/${product.slug}`} />
        <meta property="og:title" content={product.name} />
        <meta property="og:image" content={product.image} />
        <script type="application/ld+json">
          {JSON.stringify({
            '@context': 'https://schema.org',
            '@type': 'Product',
            name: product.name,
            offers: { '@type': 'Offer', price: product.price, priceCurrency: 'USD' },
          })}
        </script>
      </Helmet>
      <main><h1>{product.name}</h1><p>{product.description}</p></main>
    </>
  );
}

Note that react-helmet (without async) is deprecated. It has memory leaks under React 18 concurrent rendering and SSR issues. Always use react-helmet-async.

5.3 Prerendering at Build Time

For static content like marketing pages, blog posts, documentation, build time prerendering generates real HTML files for each route. The output ships to nginx like any static site. The vike framework (formerly vite-plugin-ssr) supports prerendering for React, Vue, and Solid by adding vike({ prerender: { partial: false } }) to the Vite plugins array. The build produces dist/index.html, dist/about/index.html, dist/products/slug-a/index.html, and so on. Each file contains fully rendered HTML with meta tags, schema, and content. The result is functionally equivalent to SSG. Alternatives: react-snap (Puppeteer based snapshotting), Vite SSR primitives without a framework wrapper, Gatsby for the small remaining legacy user base.

5.4 Dynamic Prerendering for Crawlers

For very large dynamic sites where build time prerendering is impractical, the runtime dynamic rendering pattern detects crawler User Agents in nginx and serves them a Puppeteer rendered version while serving normal users the CSR shell. nginx uses a map $http_user_agent $is_crawler block matching googlebot|bingbot|slackbot|facebookexternalhit|gptbot|claudebot|perplexitybot|chatgpt-user. In location / it rewrites to an internal /prerender_proxy when $is_crawler is true; otherwise try_files $uri $uri/ /index.html. The /prerender_proxy location is internal and proxy_pass http://127.0.0.1:3000 with X-Original-URI set to $request_uri. A self hosted prerender service runs on port 3000, fetches the URL via Puppeteer, waits for the React app to render, and returns the rendered HTML. Bubbles can host this prerender service as a Node.js process alongside the static React build. Dynamic prerendering is a stopgap; migrate to a framework when capacity allows.

5.5 The "Migrate to a Framework" Recommendation

The honest 2026 recommendation for a pure CSR React content site is to migrate. Next.js App Router, Remix, and Astro all provide server rendered HTML at first byte with minimal architectural disruption. Migration cost: 2 to 4 weeks for a 20 page marketing site, 6 to 10 weeks for a 100 page content site, 4 to 12 weeks for a SaaS dashboard. The SEO opportunity cost of staying on pure CSR usually exceeds the migration cost within a year. Section 13 covers the path.


6. Next.js App Router Patterns

The dominant React production pattern in 2026. This section covers the App Router as it intersects React. For the full Next.js framework reference see framework-nextjs.md.

6.1 File Based Routing in app/

The app/ directory replaces pages/. Each route is a folder. Inside the folder, page.tsx defines the route, layout.tsx the wrapping layout, loading.tsx the loading UI, error.tsx the error boundary, not-found.tsx the 404 view, opengraph-image.tsx the dynamic OG image, and route.ts an API handler. Folders define URL structure, files define behavior. Layouts wrap nested routes. Server Components render the static parts. Client Components handle interactivity.

6.2 The Root Layout

Every Next.js App Router project has a root app/layout.tsx. It owns the html and body tags, applies global styles, and is the natural home for site wide metadata defaults.

// app/layout.tsx
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'], display: 'swap' });

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: { default: 'Example', template: '%s | Example' },
  description: 'The canonical Example site description.',
  openGraph: { type: 'website', locale: 'en_US', siteName: 'Example' },
  robots: { index: true, follow: true },
};

export const viewport: Viewport = { themeColor: '#111', width: 'device-width', initialScale: 1 };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="en"><body className={inter.className}>{children}</body></html>;
}

6.3 generateMetadata for Dynamic Pages

Static metadata objects work for routes that do not depend on data. Dynamic routes like product pages or blog posts use generateMetadata to fetch data and produce metadata at request time or build time.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts';

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      title: post.title, description: post.excerpt, url: `/blog/${post.slug}`,
      type: 'article', publishedTime: post.publishedAt, modifiedTime: post.updatedAt,
      authors: [post.author.name],
      images: post.heroImage ? [{ url: post.heroImage, width: 1200, height: 630 }] : [],
    },
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return <article><h1>{post.title}</h1><div dangerouslySetInnerHTML={{ __html: post.html }} /></article>;
}

The metadataBase set in the root layout resolves relative URLs in alternates.canonical, openGraph.url, and openGraph.images. Always set it explicitly. Defaulting to the request host introduces correctness bugs in preview deployments.

6.4 Canonical Handling

The canonical strategy in App Router uses alternates.canonical in the metadata. Set it explicitly per route. Never derive from headers().get('host') because that captures preview hosts and breaks indexing.

export const metadata: Metadata = {
  title: 'About',
  alternates: { canonical: '/about' },
};

For international setups with hreflang the metadata adds an alternates.languages map keyed by locale to alternate URLs, plus an x-default entry. See framework-hreflang.md for the full pattern.

6.5 Open Graph Image Generation

Next.js App Router supports dynamic OG image generation via opengraph-image.tsx files. The file colocates with the route and produces an image at request time or build time using the Vercel OG library, which runs Satori under the hood.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'nodejs';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return new ImageResponse(
    <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center',
      background: '#111', color: '#fff', fontSize: 72, fontWeight: 700, padding: 80,
      width: '100%', height: '100%' }}>
      <div>{post?.title ?? 'Untitled'}</div>
      <div style={{ fontSize: 36, marginTop: 24, opacity: 0.7 }}>example.com</div>
    </div>,
    { ...size },
  );
}

The dynamic OG image becomes the default for the route. Social previewers receive a properly sized PNG with the post title rendered into it.

6.6 Sitemap and Robots

App Router supports a file based sitemap and robots convention. app/sitemap.ts exports a function that returns an array of sitemap entries. app/robots.ts exports a function that returns the robots configuration.

// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://example.com';
  const postSlugs = await getAllPostSlugs();
  return [
    { url: baseUrl, changeFrequency: 'weekly', priority: 1.0 },
    { url: `${baseUrl}/about`, changeFrequency: 'monthly', priority: 0.7 },
    ...postSlugs.map((slug) => ({
      url: `${baseUrl}/blog/${slug}`,
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    })),
  ];
}

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] }],
    sitemap: 'https://example.com/sitemap.xml',
  };
}

For sites with more than 50,000 URLs, the sitemap function should return an index that references multiple sitemap files. Next.js handles the splitting automatically with the generateSitemaps function pattern.


7. Next.js Pages Router Legacy

The older Next.js pattern. Still in production on plenty of sites. New projects should not start here. Existing projects do not need to migrate purely for SEO reasons but should plan migration when complex layouts or streaming become valuable.

7.1 The pages/ Directory

The legacy file based routing pattern. Each file in pages/ becomes a route. The file exports a default React component plus optional data fetching functions. _app.tsx wraps all pages, _document.tsx controls the HTML shell, pages/index.tsx is the home route, pages/blog/[slug].tsx is a dynamic route, pages/api/*.ts files become API routes.

7.2 Data Fetching Functions

Three primary functions, applied per page.

getStaticProps fetches data at build time. The page becomes part of the static export. Combine with getStaticPaths for dynamic routes:

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = await getAllPostSlugs();
  return { paths: slugs.map((slug) => ({ params: { slug } })), fallback: 'blocking' };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await getPost(params!.slug as string);
  if (!post) return { notFound: true };
  return { props: { post }, revalidate: 3600 };
};

getServerSideProps fetches data on every request. Use when data must be current and caching is not acceptable. getInitialProps is the original Next.js pattern, deprecated. Avoid in new code.

7.3 The Head Component

Per page meta tags use the next/head component with <title>, <meta name="description">, <link rel="canonical">, and Open Graph tags as direct children.

7.4 When to Keep Pages Router

Plenty of production sites in 2026 still run Pages Router. The 2024 to 2025 migration window was when App Router became unambiguously better. Sites that did not migrate in that window probably have reasons: legacy data fetching patterns, custom Next.js plugins, complex _document.tsx overrides, or simply lack of engineering capacity. The right framing for clients on Pages Router in 2026: it is not broken. It will keep working. Migration is a project, not an emergency. Budget six to twelve weeks for a medium sized site.

7.5 When to Migrate to App Router

Migrate when one of these triggers fires: the team wants Server Components, streaming SSR, parallel or intercepting routes, the new metadata API, the file based loading and error UI, or the team plans a major feature build and would rather build it on the current pattern than the legacy one. The SEO benefit is real but secondary; the architectural simplification is the primary driver.


8. React 19 Hooks for SEO

React 19 introduced or stabilized a handful of hooks that affect the SEO surface, mostly through hydration timing and HTML output quality.

8.1 useId

useId generates stable unique IDs that match between server render and client hydration. Use it for accessibility associations like form labels, ARIA references, and any case where you need a unique ID per component instance. Call const id = useId() and use it as the htmlFor of a label and the id of the matching input. The SEO impact is indirect: stable IDs prevent hydration mismatches, which prevents React from blowing away server rendered DOM and re rendering from scratch. A hydration mismatch is a flash of empty content that Lighthouse measures as poor Cumulative Layout Shift.

8.2 useDeferredValue

useDeferredValue lets you mark an expensive derived value as low priority. The hook returns a previous value while the new one is being computed in the background. Wrap the expensive computation in a useDeferredValue(query) call and pass the deferred query into the filter. The SEO impact: better INP scores on interactive pages. The deferred work does not block input handling.

8.3 useTransition

useTransition marks state updates as non urgent. The hook returns an isPending flag and a startTransition function. The pattern shows up in tabs, filters, and any case where a state change triggers expensive rendering. Wrap the setState call in startTransition(() => setTab('b')) and render a loading state when isPending is true. The SEO impact: same as useDeferredValue. Better INP. Lower main thread work during interactions.

8.4 useOptimistic

useOptimistic updates the UI optimistically while a server action runs. The hook reverts automatically if the action fails.

'use client';
import { useOptimistic } from 'react';
import { addComment } from './actions';

export function CommentList({ comments, postId }: { comments: Comment[]; postId: string }) {
  const [optimistic, addOptimistic] = useOptimistic(comments,
    (state, newComment: Comment) => [...state, newComment]);
  async function onSubmit(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimistic({ id: 'temp', text, author: 'You', createdAt: new Date().toISOString() });
    await addComment(postId, text);
  }
  return (
    <>
      <ul>{optimistic.map((c) => <li key={c.id}>{c.text}</li>)}</ul>
      <form action={onSubmit}><input name="text" /><button>Post</button></form>
    </>
  );
}

The SEO impact: minimal direct impact. Indirect impact: better perceived performance, better engagement metrics, fewer abandoned forms.

8.5 useFormStatus and useActionState

React 19 provides built in form state primitives that pair with Server Actions. useFormStatus exposes the pending state of the enclosing form. useActionState lets you derive state from form submission results. Section 11.2 shows the canonical pattern in full. The SEO impact: forms work without JavaScript when paired with Server Actions. Progressive enhancement is the default. The form submits to the server even if the React bundle fails to load, which matters for low end devices and flaky networks.


9. Schema Implementation in React

Structured data is the single highest leverage SEO surface in 2026 because AI Overviews, ChatGPT search, and Perplexity all consume schema heavily. Getting it right in React requires picking a pattern and applying it consistently.

9.1 JSON LD via dangerouslySetInnerHTML

The canonical pattern. Inject a script tag with JSON LD content into the head or body. React renders the script tag, the JSON serializes correctly, and crawlers parse it.

// components/JsonLd.tsx
export function JsonLd({ schema }: { schema: Record<string, unknown> | Record<string, unknown>[] }) {
  return <script type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />;
}

// Usage inside a Server Component
const articleSchema = {
  '@context': 'https://schema.org', '@type': 'Article',
  headline: post.title, datePublished: post.publishedAt, dateModified: post.updatedAt,
  author: { '@type': 'Person', name: post.author.name },
  publisher: { '@type': 'Organization', name: 'Example',
    logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' } },
  mainEntityOfPage: { '@type': 'WebPage', '@id': `https://example.com/blog/${post.slug}` },
};
return <><JsonLd schema={articleSchema} /><article>...</article></>;

The dangerouslySetInnerHTML prop name is intentionally alarming but the use case is safe when the input is a JSON object you control. Never inject raw user input into this prop without escaping.

9.2 The @id Graph Pattern

The advanced pattern that powers entity centric SEO. Instead of multiple disconnected schema blocks, you build a single graph that references entities by @id. The result is a knowledge graph payload that AI crawlers consume more cleanly than fragmented blocks.

// lib/schema.ts
const SITE = 'https://example.com';

export const organizationSchema = {
  '@type': 'Organization', '@id': `${SITE}/#organization`,
  name: 'Example', url: SITE,
  logo: { '@type': 'ImageObject', url: `${SITE}/logo.png` },
  sameAs: ['https://twitter.com/example', 'https://linkedin.com/company/example'],
};

export const websiteSchema = {
  '@type': 'WebSite', '@id': `${SITE}/#website`, url: SITE, name: 'Example',
  publisher: { '@id': `${SITE}/#organization` }, inLanguage: 'en-US',
};

export function buildGraph(...nodes: Record<string, unknown>[]) {
  return { '@context': 'https://schema.org', '@graph': [organizationSchema, websiteSchema, ...nodes] };
}

A route assembles its graph by calling buildGraph with one Article node (ID ${SITE}/blog/${slug}#article, referencing the author by @id, the publisher by @id, and isPartOf the website by @id) and one Person node for the author with its own canonical URL. The graph pattern matters because it expresses relationships. The Article references its Author by ID. The Author has its own canonical URL. The WebPage references the WebSite. The WebSite references the Organization. AI crawlers and Google Knowledge Graph both consume the relationships, not just the individual entities.

9.3 schema-dts for Type Safety

The schema-dts npm package provides TypeScript types generated from the official Schema.org vocabulary. Using it catches schema bugs at compile time.

import type { WithContext, Article } from 'schema-dts';

export function articleSchema(post: Post): WithContext<Article> {
  return {
    '@context': 'https://schema.org', '@type': 'Article',
    headline: post.title, datePublished: post.publishedAt, dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.author.name },
  };
}

TypeScript flags typos in property names, wrong value types, and missing required fields. The cost is a build time dependency. The benefit is that schema regressions become impossible to ship.

9.4 Per Page Schema Injection

The pattern: each route owns its schema. The root layout injects site wide schema (Organization, WebSite). Each page injects page specific schema (Article, Product, FAQPage, BreadcrumbList). Crawlers see both blocks and parse the union. For schema theory and the full type catalogue see framework-schema.md.


10. Client Components and Hydration

The boundary between server rendered HTML and client side interactivity is where most React performance bugs and SEO subtleties live.

10.1 The 'use client' Directive

A file marked with 'use client' at the top is a Client Component module. The module ships to the browser. Everything imported from it transitively is part of the client bundle. The module also runs on the server during SSR to produce initial HTML. A simple Counter component with 'use client' and useState(0) renders server side first (producing <button>0</button> in the HTML), then ships to the client, then hydrates. After hydration the onClick handler is live.

10.2 The Hydration Boundary

The boundary is where Server Components and Client Components meet. A Server Component can render a Client Component. A Client Component cannot directly import a Server Component (but it can receive one as a children prop). The render flow: a server page component runs on the server, awaits data, renders any server children inline, and renders client components (server side first pass for initial HTML, then ships the client module for hydration). The HTML response contains the entire tree. JavaScript shipped to the browser contains only the client modules and their dependencies.

10.3 React 19 Hydration Error Reporting

React 19 dramatically improved hydration error messages. The old "Text content does not match server rendered HTML" gave no clue where the mismatch happened. React 19 shows the specific component, the specific prop, and a diff between server and client output. Common causes in 2026: new Date() or Date.now() rendered directly into JSX (server and client time differ; format dates server side and pass strings), Math.random() rendered directly (use useId or generate IDs server side), browser only globals like window or navigator accessed without a check (use useEffect or typeof window !== 'undefined' guards), time zone sensitive rendering (render times as ISO strings server side and let client components format with Intl.DateTimeFormat after hydration), Markdown or HTML transformation that differs between Node.js and the browser (do all transformation server side).

10.4 The SEO Impact of Hydration Mismatches

When hydration fails, React falls back to client side rendering for the mismatched subtree. The server rendered DOM gets replaced with the client rendered DOM. This has three SEO consequences. Layout shift: the replacement causes CLS. If the mismatched subtree contains the LCP element, the Core Web Vitals score takes a direct hit. Index quality: Googlebot indexes the server rendered HTML. If the client renders something different, the index does not reflect what users see. Console errors: Lighthouse SEO and accessibility audits flag hydration errors and drop the score. The right response is to fix the mismatch, not suppress it. The suppressHydrationWarning prop exists for specific legitimate cases (a timestamp that legitimately differs between server and client) but should not be used to paper over real bugs.

10.5 The "Too Many Client Components" Anti Pattern

A frequent mistake in App Router projects: marking too many components as Client Components reflexively. The pattern shows up when a developer adds 'use client' to a layout or a high level container because one nested component needs interactivity.

The fix: push 'use client' down the tree. Keep the layout as a Server Component. Mark only the leaf interactive components. If a Hero contains a static heading, a static description, and an interactive SignupForm, the Hero itself should stay a Server Component, and only the SignupForm file should carry the 'use client' directive. The benefit: the bulk of the Hero content stays server rendered. JavaScript bytes shipped to the client only include SignupForm and its dependencies. The page loads faster, scores better on Core Web Vitals, and indexes more reliably.


11. Forms and Server Actions

React 19 stabilized Server Actions, a pattern where form submissions invoke server side functions directly without manually wiring API routes. It matters for SEO because it enables progressive enhancement by default.

11.1 The Basic Server Action

A Server Action is a function marked with the 'use server' directive that runs on the server. The function can be imported and used as a form action prop.

// app/contact/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { sendContactEmail } from '@/lib/email';

export async function submitContact(formData: FormData) {
  const email = formData.get('email');
  const message = formData.get('message');
  if (typeof email !== 'string' || typeof message !== 'string') throw new Error('Invalid');
  await sendContactEmail({ email, message });
  redirect('/contact/thanks');
}

// app/contact/page.tsx
import { submitContact } from './actions';

export default function ContactPage() {
  return (
    <main>
      <h1>Contact</h1>
      <form action={submitContact}>
        <input name="email" type="email" required />
        <textarea name="message" required />
        <button type="submit">Send</button>
      </form>
    </main>
  );
}

The form works without JavaScript. If React fails to load, the browser submits the form to the action URL the framework generated. The server function runs. The redirect fires. The user lands on the thanks page.

11.2 useActionState for Validation

useActionState wraps the action and exposes state for validation errors, success messages, and pending state. The action receives the previous state as its first argument and the form data as the second, and returns the new state.

'use server';
export type ContactFormState = { errors: { email?: string; message?: string }; success: boolean };

export async function submitContact(
  prev: ContactFormState, formData: FormData,
): Promise<ContactFormState> {
  const email = (formData.get('email') ?? '').toString();
  const message = (formData.get('message') ?? '').toString();
  const errors: ContactFormState['errors'] = {};
  if (!email.includes('@')) errors.email = 'Valid email required';
  if (message.length < 10) errors.message = 'Message too short';
  if (Object.keys(errors).length > 0) return { errors, success: false };
  await sendContactEmail({ email, message });
  return { errors: {}, success: true };
}

The matching client component wraps useActionState(submitContact, initialState), reads state.errors to render error messages inline, and renders state.success for a confirmation. The form still works without JavaScript because the action attribute points to a real server endpoint.

11.3 The Progressive Enhancement Pattern

The pattern that separates Server Actions from old style React forms: the form works without JavaScript. The action attribute points to a real endpoint the framework generated. The browser submits the form natively. The action runs server side and produces a real HTTP response. When JavaScript loads, React intercepts the submission, calls the action via fetch under the hood, and updates the UI without a full page navigation. The form does not depend on JavaScript loading. The SEO consequence: search engines can submit form actions when crawling. The contact form, the search form, the filter form all work even for crawlers that do not execute JavaScript. The progressive enhancement floor is HTML plus an HTTP form submission.

11.4 Cross Reference

For comprehensive form patterns, validation rules, honeypot anti spam, and conversion rate optimization see framework-formoptimization.md.


12. State Management for SEO Apps

State management in React 2026 has settled into a small number of patterns. The SEO consideration cuts across all of them: keep critical data in the URL or in server rendered HTML, not in client only state.

12.1 Server State vs Client State

There are two kinds of state, and they need different tools. Server state lives on a server (database, API, file system) and gets fetched, cached, synchronized, and mutated. Examples: products, blog posts, user profiles, comments. Tools: React Server Components, TanStack Query, SWR. Client state lives only in the browser as UI state, form state, ephemeral interactions. Examples: which tab is open, which modal is showing, the value of a controlled input, scroll position. Tools: useState, useReducer, Zustand, Jotai. Conflating the two is the root cause of most React performance and SEO issues. Putting server state in Redux means the UI has to wait for client side fetches before content is available. Putting UI state in TanStack Query means cache invalidation logic gets weird.

12.2 TanStack Query for Server State

TanStack Query (formerly React Query) is the dominant client side server state library in 2026. It caches, deduplicates, revalidates, and synchronizes server data. In an RSC enabled app, most server state should live in Server Components. TanStack Query handles the residual client side fetching: real time data, mutations that need optimistic updates, infinite scrolling lists, polling. A polling pattern wraps a 'use client' component with useQuery({ queryKey: ['comments', postId], queryFn: () => fetch(...).then(r => r.json()), refetchInterval: 30000 }). The SEO consideration: the initial comment list belongs in the Server Component (server rendered HTML, indexable). The polling for new comments belongs in the Client Component with TanStack Query.

12.3 Zustand for Client State

Zustand is the dominant lightweight client state library in 2026. It avoids the boilerplate of Redux and pairs cleanly with React 19. A typical cart store calls create<CartState>((set) => ({ items: [], add: (item) => set((s) => ({ items: [...s.items, item] })), remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })) })). The SEO consideration: client state is invisible to crawlers. In practice, cart state is purely client side and has no SEO surface.

12.4 Redux for Complex Apps

Redux Toolkit remains the right tool for very large applications with complex client state, time travel debugging requirements, or strict action audit trails. The footprint and ceremony are higher than Zustand. The benefit is the ecosystem (Redux DevTools, Redux Persist, middleware) and the predictability. For SEO sensitive sites, Redux is overkill. Reach for Zustand or built in React state first.

12.5 The Critical SEO Rule

Critical content data belongs in server rendered HTML, not in client side state. The filter the user selects, the sort order, the search query, the pagination offset all belong in the URL as query parameters. The page renders server side with those parameters. The HTML is indexable.

type Props = { searchParams: Promise<{ q?: string; sort?: string; page?: string }> };

export default async function SearchPage({ searchParams }: Props) {
  const { q = '', sort = 'relevance', page = '1' } = await searchParams;
  const results = await runSearch({ query: q, sort, page: parseInt(page, 10) });
  return <main><h1>Results for &quot;{q}&quot;</h1><ResultList items={results.items} /></main>;
}

The sort controls and pagination links update the URL. The page re renders server side with the new parameters. Each variant is a real URL with real HTML. The anti pattern: keeping the search query and sort order in Zustand or React state. The URL stays at /search. The page renders the same HTML for every search. The search is invisible to crawlers.


13. Migration Patterns

Migration between React generations, framework patterns, and rendering modes is common in 2026. For URL preservation discipline see framework-migration.md.

13.1 React 18 to React 19

The library upgrade. React 19 is largely backward compatible with React 18. The breaking changes are the removal of some legacy APIs and a stricter mode of error handling.

pnpm add react@19 react-dom@19 @types/react@19 @types/react-dom@19

The most common issues to fix: defaultProps on function components is removed (use default parameter values), propTypes runtime checking is removed (use TypeScript), the legacy context API is removed (use the modern context API), findDOMNode is removed (use refs), string refs are removed (use callback refs or useRef). Most React 18 codebases upgrade to React 19 in a single sprint. The bigger lift is adopting the new features (Server Components, Server Actions, the new hooks). Those are framework decisions, not library decisions.

13.2 Pages Router to App Router

The most common Next.js migration in 2026. The mental model shift is significant: data fetching moves from page level functions to component level async functions. The folder structure changes. The metadata API changes. The recommended approach is incremental migration. Next.js supports running App Router and Pages Router side by side. Set up app/ alongside pages/, create the root layout, migrate the simplest static page first, verify, then migrate routes in order of complexity. When all routes have moved, delete pages/ and update the Next.js config. The data fetching change requires the most attention: getStaticProps and getServerSideProps do not exist in App Router. The equivalent is to make the page component async and fetch directly inline.

Before (Pages Router):

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await getPost(params!.slug as string);
  if (!post) return { notFound: true };
  return { props: { post }, revalidate: 3600 };
};
export default function PostPage({ post }: { post: Post }) { return <article>...</article>; }

After (App Router):

import { notFound } from 'next/navigation';
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return <article>...</article>;
}

The revalidate: 3600 from the old code moves to the fetch call (fetch(url, { next: { revalidate: 3600 } })) or to the route segment config (export const revalidate = 3600;).

The migration cost: 4 to 8 weeks for a 30 to 50 page site, longer for sites with complex data fetching patterns or heavy use of Next.js plugins.

13.3 CSR SPA to SSR or Framework

The biggest migration. The site moves from a pure client side React app to a server rendered framework. The target is almost always Next.js App Router in 2026, with Remix as the second choice for form heavy applications.

The steps: audit the existing site (every route, URL parameter, meta tag, canonical, redirect), create a new Next.js project, set up the root layout with site wide metadata matching the audit, migrate routes in order of SEO importance (home page first, then top traffic pages, then conversion pages), replicate existing data fetching using Server Components, replace react-router-dom <Link> with next/link, replace react-helmet-async with metadata exports, replicate every redirect from the old site in next.config.js or middleware, set up staging and crawl old vs new with screaming frog, address discrepancies, cut over DNS with monitoring on 404s and console errors and search engine behavior.

The migration cost: 6 to 16 weeks for a 50 to 100 page site, depending on the complexity of the existing app and the team's familiarity with the target framework. The cheaper alternative: stay on CSR and add build time prerendering. The site gets indexed reasonably well. The migration to a full framework can happen later.

13.4 The General Migration Discipline

Three rules apply to every React migration. Preserve URLs: every old URL must either work or 301 redirect to a new URL. Never let URLs 404. Never let URLs redirect more than once. URL changes are SEO losses that take months to recover from. Preserve metadata: title, description, canonical, Open Graph all need to match the old site or improve on it. Regressions are visible in Search Console within days. Preserve schema: if the old site shipped structured data, the new site must ship at least as much. Schema regressions are visible in rich result reports. See framework-migration.md for the comprehensive migration discipline.


14. Bubbles Hosted React

Joseph's deployment pattern for React applications. Every React site he hosts runs on Bubbles, a Debian 12 amd64 server at IP 169.155.162.118. No third party edge proxy. No platform vendor in the middle. The stack is nginx in front, Node.js plus PM2 cluster behind, with static React SPA builds served directly from disk for projects that do not need a Node runtime.

14.1 The Bubbles Host

Hardware and OS: Debian 12, 16 GB RAM, NVMe storage, single public IP. The server hosts dozens of vhosts under /var/www/ and /var/www/sites/. nginx terminates TLS for every vhost via Let's Encrypt certificates auto renewed by certbot. PM2 manages all Node.js processes. Node.js 20 LTS is the default runtime, with some projects pinned to Node 22.

14.2 The Next.js Deployment Pattern

For an App Router project, the deployment is a Node.js process behind an nginx reverse proxy. The project lives at /var/www/sites/example.com/ and runs on a private port (4001, 4002, etc). The PM2 ecosystem config exports apps: [{ name: 'example-com', script: 'node_modules/next/dist/bin/next', args: 'start -p 4001', cwd: '/var/www/sites/example.com', instances: 2, exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: '4001' }, max_memory_restart: '512M' }]. The deploy script runs git pull && pnpm install --frozen-lockfile && pnpm build && pm2 reload ecosystem.config.js && pm2 save.

The nginx vhost terminates TLS, sets Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy headers, aliases /_next/static/ to /var/www/sites/example.com/.next/static/ with one year immutable caching, and proxies everything else to http://127.0.0.1:4001 with the standard Host, X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto headers. gzip is enabled for text and JavaScript content types.

14.3 The Pure SPA Static Deployment Pattern

For a Vite React SPA that does not need a Node runtime, nginx serves the built static files directly from /var/www/sites/example.com/dist/. No PM2. No Node process. The deploy script omits pm2 reload. The nginx vhost root points at dist/, location /assets/ gets Cache-Control public, max-age=31536000, immutable, location = /index.html gets Cache-Control no-cache, no-store, must-revalidate, and location / does try_files $uri $uri/ /index.html.

14.4 The PM2 Cluster Mode

PM2 cluster mode runs multiple Node.js processes behind a load balancer that PM2 manages internally. The number of instances should typically be 2 to 4 per vhost, depending on memory and CPU pressure. The Bubbles server hosts many vhosts so the per vhost footprint must stay small. Use pm2 list, pm2 reload <app>, pm2 restart <app>, pm2 save, and pm2 startup to manage processes. The systemd startup ensures PM2 and all its apps come back after a server reboot.

14.5 Let's Encrypt Auto Renewal

Certificates renew automatically via certbot's cron job. The renewal hook /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh runs systemctl reload nginx after a successful renewal.

14.6 The "No Third Party Proxy" Discipline

Joseph's deployment philosophy: every byte that leaves Bubbles goes directly to the user. No edge proxy of any kind. The benefits: Sovereignty: no third party can take the site down or change pricing, terms of service, or feature availability. Debuggability: every request hits nginx logs on Bubbles; every response originates from a process on Bubbles; no edge cache layer hides the truth. Performance: the Bubbles host is on a fast network with a clean public IP; for traffic levels under 10 million page views a month the latency difference between direct origin and a global edge layer is negligible. Cost: zero proxy bills; the Bubbles server costs are fixed monthly. Privacy: no third party intercepts user traffic or sets edge cookies. The trade off: scaling beyond a single server requires more work. For Joseph's portfolio the trade off is correct.

14.7 Common Gotchas

Trailing slash policy. Next.js defaults to no trailing slash. Set the policy in next.config.js and make sure nginx does not rewrite paths in a conflicting way. Static asset caching. Hashed assets get public, max-age=31536000, immutable. index.html and the root document get no-cache, no-store, must-revalidate. Getting this wrong means stale clients see old versions for hours or days. PM2 memory leaks. Set max_memory_restart in the PM2 config. Without this, a memory leak can OOM the server. nginx worker connections. Default is 768. For high traffic vhosts increase to 4096 or higher in /etc/nginx/nginx.conf. Let's Encrypt rate limits. Production rate limit is 50 certificates per registered domain per week. Sitemap on App Router. app/sitemap.ts works for sites under 50,000 URLs. For larger sites use generateSitemaps to split. Open Graph image cost. Dynamic OG images via opengraph-image.tsx run Satori per request. Cache or generate at build time for high traffic sites. "Ports in use" failure. When a deploy fails partway, the old PM2 process may hold the port. Fix: pm2 delete <app> followed by a fresh pm2 start ecosystem.config.js.


End of Framework

React in 2026 is best understood as a library consumed by frameworks. The framework choice determines the SEO outcome. Next.js App Router with React 19 Server Components is the default for new content sites. Remix and React Router 7 fit form-heavy apps. Astro with React islands fits content sites wanting component reuse without client React. Pure CSR React is malpractice for any public content surface. Bubbles serves every byte directly via Debian + nginx + Node.js + PM2 cluster.

Companions: framework-cross-stack-implementation.md, framework-nextjs.md, framework-remix.md, framework-hydrogen.md, framework-schema.md, framework-hreflang.md, framework-international.md, framework-migration.md, framework-pageexperience.md, framework-headless.md, framework-technicalseo.md, framework-mobileseo.md, framework-accessibility.md, framework-aicitations.md, framework-aioverviews.md, framework-formoptimization.md, framework-tailwind.md.

Want this framework implemented on your site?

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

See Engine Optimization service ›