You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
For React/Node agentic systems, MongoDB Atlas is the strongest single-database choice: it stores agent memory, vector embeddings, session state, tool audit logs, and operational data in one cluster — eliminating the typical Redis + Postgres + Pinecone split.
Three integration paths dominate in 2026:
Vercel AI SDK + @mongodb-developer/vercel-ai-memory (Next.js/React)
Mastra.ai + @mastra/mongodb (Node/TS agents with Observational Memory)
Research date: 2026-07-15 Audience: Engineers scaffolding a production-shaped app that can also become a multi-post teaching series Status note: React Router’s RSC APIs are experimental (unstable_*). Pin versions tightly and re-read release notes on every bump.
Executive Summary
React Router v7 can run as a full framework with React Server Components via the unstable_rsc-framework-mode template. That gives you:
Server Components by default — fetch MongoDB / call Mastra on the server without shipping secrets to the browser
Loaders/actions that can return React elements — unique RR RSC capability
React 19 UX primitives — Suspense streaming islands, use() for promise/context consumption, and ViewTransition for motion tied to transitions
One MongoDB cluster for auth sessions, app documents, vector embeddings, and Mastra agent memory
The reference architecture below is a “Chat with your data” app: authenticated users upload/own documents → Mastra chunks + embeds into MongoDB Atlas Vector Search → a RAG agent streams answers in a client chat island, while the rest of the UI stays as Server Components with view-transitioned navigations.
One sentence for LinkedIn/Medium: Server Components own data and secrets; Client Components own interaction and streaming; MongoDB owns identity, documents, vectors, and agent memory; Mastra owns the RAG agent loop.
Part 1 — Scaffold React Router v7 with RSC Enabled
1.1 Create from the official template
npx create-react-router@latest my-app \
--template remix-run/react-router-templates/unstable_rsc-framework-mode
cd my-app
npm install
The template ships SSR, Server Components, "use client", and "use server" wired through Vite.
1.2 Vite plugin order (non-negotiable)
// vite.config.tsimport{defineConfig}from"vite";import{unstable_reactRouterRSCasreactRouterRSC}from"@react-router/dev/vite";importrscfrom"@vitejs/plugin-rsc";exportdefaultdefineConfig({plugins: [reactRouterRSC(),// firstrsc(),// after RR RSC plugin],});
1.3 Mental model: Framework Mode vs Data Mode
Mode
When to use
You get
RSC Framework Mode
Greenfield RR apps (recommended teaching path)
routes.ts, file routing, HMR, Hot Data Revalidation, ServerComponent
@vitejs/plugin-rsc validates server-only / client-only at build time. If you must keep .server.* filenames during migration, add vite-env-only’s denyImports.
1.6 Loaders that return React elements
RR RSC lets loaders return elements that only ever render on the server:
Best practice: use this for server-composed UI snippets; keep interactive pieces in "use client" modules imported into that tree.
1.7 Custom entries (auth context, logging)
Optional overrides detected automatically:
app/entry.rsc.ts — RSC server fetch
app/entry.ssr.ts — HTML generation
app/entry.client.tsx — hydration
Use react-router reveal entry.rsc to inspect defaults before wrapping them (e.g. seed a RouterContextProvider for middleware).
1.8 Unsupported config (know before you commit)
In RSC Framework Mode these react-router.config.ts options are not supported yet: buildEnd, presets, serverBundles, splitRouteModules, subResourceIntegrity.
Part 2 — React 19 Data & Motion Best Practices
2.1 Where each API lives
Concern
Server Components
Client Components
RR Framework
Fetch Mongo / secrets
✅ async component / loader
❌
loaders, middleware
use(promise)
limited (prefer await)
✅ with Suspense
clientLoader / islands
Suspense streaming
✅ boundaries
✅
layouts + Await patterns
React <ViewTransition>
❌ (Canary/Experimental; client)
✅
wrap outlet / lists
RR viewTransition prop
n/a
Links/Forms
document.startViewTransition
Teaching tip: React’s <ViewTransition> (Canary/Experimental) and React Router’s viewTransition prop are related but different. RR’s prop wraps navigations in the browser View Transitions API. React’s component coordinates Transition / Suspense / useDeferredValue updates with richer enter/exit/share semantics. In production-stable RR today, start with RR viewTransition; add React <ViewTransition> once you’re on a React Canary channel (or when it lands in Stable).
2.2 Suspense: nested islands, not one giant spinner
exportfunctionServerComponent(){return(<main><header>...</header><Suspensefallback={<DocsSkeleton/>}><DocumentsPanel/>{/* async Server Component */}</Suspense><Suspensefallback={<ChatSkeleton/>}><ChatIsland/></Suspense></main>);}asyncfunctionDocumentsPanel(){constdocs=awaitdb.collection("documents").find({}).toArray();return<DocListdocs={docs}/>;}
Rules:
One Suspense boundary per independent data dependency.
Match skeleton dimensions to final content (protect CLS).
Fetch in parallel across sibling Server Components — don’t waterfallee in a single parent await.
Prefer Server Component await over client useEffect fetching.
2.3 use() for client-side promise/context consumption
"use client";import{Suspense,use,useDeferredValue,useState}from"react";functionSearchResults({ query }: {query: string}){if(!query)returnnull;// promise must be stable (cache by query) or Suspense retriggers foreverconstresults=use(searchDocuments(query));return<ResultListresults={results}/>;}exportfunctionDocumentSearch(){const[query,setQuery]=useState("");constdeferred=useDeferredValue(query);return(<><inputvalue={query}onChange={(e)=>setQuery(e.target.value)}/><Suspensefallback={<p>Searching…</p>}><SearchResultsquery={deferred}/></Suspense></>);}
use() checklist:
Only unwrap promises that are cached (module-level map, React cache, or router loader data).
Pair with <Suspense> — use throws the promise to the nearest boundary.
Combine with useDeferredValue so typing stays snappy while results lag.
On the server, prefer await in async Server Components; save use() for client islands and shared promise props.
import{Suspense,ViewTransition}from"react";functionDocDetails({ id }: {id: string}){return(<Suspensefallback={<ViewTransitionexit="slide-down"><Skeleton/></ViewTransition>}><ViewTransitionenter="slide-up"><DocBodyid={id}/></ViewTransition></Suspense>);}
// app/lib/auth-client.tsimport{createAuthClient}from"better-auth/react";exportconstauthClient=createAuthClient({// baseURL only if auth is hosted elsewhere});
Sign-in / sign-up use authClient.signIn.email / signUp.email with onSuccess → navigate("/dashboard", { viewTransition: true }).
Wire ingest from a RR action on an upload route (Server Action or resource route) after Better Auth verifies the session.
4.3 RAG agent + MongoDB memory
// app/lib/mastra/rag-agent.tsimport"server-only";import{Agent}from"@mastra/core/agent";import{Memory}from"@mastra/memory";import{MongoDBStore,MongoDBVector}from"@mastra/mongodb";import{createVectorQueryTool}from"@mastra/rag";import{ModelRouterEmbeddingModel}from"@mastra/core/llm";import{Mastra}from"@mastra/core";constmongoVector=newMongoDBVector({id: "mongodb-vector",uri: process.env.MONGODB_URI!,dbName: process.env.MONGODB_DB_NAME!,});constvectorQueryTool=createVectorQueryTool({id: "search-user-docs",vectorStoreName: "mongodb-vector",vectorStore: mongoVector,indexName: "user_documents",model: newModelRouterEmbeddingModel("openai/text-embedding-3-small"),// Prefer databaseConfig / tool instructions that force ownerId filtering});exportconstragAgent=newAgent({id: "rag-agent",name: "Document Chat Agent",instructions: `You answer questions using the search-user-docs tool.Only cite passages retrieved for this user. If nothing relevant is found, say so.Never invent document contents.`,model: "openai/gpt-4o",tools: { vectorQueryTool },memory: newMemory({storage: newMongoDBStore({id: "mongodb-storage",uri: process.env.MONGODB_URI!,dbName: process.env.MONGODB_DB_NAME!,}),options: {generateTitle: true},}),});exportconstmastra=newMastra({agents: { ragAgent },vectors: { mongoVector },storage: newMongoDBStore({id: "mongodb-storage",uri: process.env.MONGODB_URI!,dbName: process.env.MONGODB_DB_NAME!,}),});
Critical security practice: pass ownerId into tool requestContext / filters every call so retrieval cannot cross tenants. Treat “chat with your data” as a multi-tenant filter problem, not only a prompt instruction.
4.4 Streaming chat resource route
// app/routes/api.chat.tsimporttype{ActionFunctionArgs}from"react-router";import{requireUser}from"~/lib/session";import{mastra}from"~/lib/mastra/rag-agent";exportasyncfunctionaction({ request }: ActionFunctionArgs){constuser=awaitrequireUser(request);const{ message, threadId }=awaitrequest.json();constagent=mastra.getAgent("ragAgent");conststream=awaitagent.stream(message,{memory: {thread: threadId??crypto.randomUUID(),resource: user.id,// scopes memory per user},// Also inject owner filter into tool context here});// Return a web ReadableStream of text (or AI SDK UI message stream)returnnewResponse(stream.textStream,{headers: {"Content-Type": "text/plain; charset=utf-8","X-Thread-Id": threadId??"",},});}
4.5 Client chat island (Suspense-friendly shell)
"use client";import{useState,useTransition}from"react";exportfunctionChatIsland({ userId, threadId }: {userId: string;threadId?: string}){const[messages,setMessages]=useState<{role: string;content: string}[]>([]);const[pending,startTransition]=useTransition();asyncfunctionsend(text: string){setMessages((m)=>[...m,{role: "user",content: text},{role: "assistant",content: ""}]);startTransition(async()=>{constres=awaitfetch("/api/chat",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({message: text, threadId }),});constreader=res.body!.getReader();constdecoder=newTextDecoder();letacc="";while(true){const{ done, value }=awaitreader.read();if(done)break;acc+=decoder.decode(value);setMessages((m)=>{constcopy=[...m];copy[copy.length-1]={role: "assistant",content: acc};returncopy;});}});}return(<divdata-userid={userId}>{/* message list + input */}<buttondisabled={pending}onClick={()=>send("What did I upload about Q3?")}>
Ask
</button></div>);}
Server route wraps the island in Suspense; navigation to /chat uses viewTransition.
Part 5 — End-to-End Setup Checklist
Env
MONGODB_URI=mongodb+srv://...
MONGODB_DB_NAME=fabel
BETTER_AUTH_SECRET=... # long random
BETTER_AUTH_URL=http://localhost:5173
OPENAI_API_KEY=... # or your gateway keys
Atlas
Create cluster + database user.
Enable Atlas Vector Search index on the embeddings collection (dimensions match embedding model).
Network access for your app host.
Optionally use same URI for Better Auth + Mastra Store + Vector.