Want to jump ahead or use this as a reference? All the code from this tutorial is available in this public GitHub repository:
https://github.com/ayoubphy/react-router-trpc-prisma-better-auth
Use it to compare your code, experiment, or simply get a head start!
1. Create A React Router v7 App
Setting up React Router is straightforward. It comes pre configured with Tailwind CSS v4. Simply run the following command in your terminal
npx create-react-router@latest my-app
By default, React Router configures TypeScript path aliases using the ~ symbol. However, this tutorial uses @ for path aliases. You have two options: either update all import statements to use ~/ instead of @/, or modify the tsconfig.json file to use @/ as the path alias
-"~/*": ["./app/*"]
+"@/*": ["./app/*"]
2. Set Up Prisma
Let's initialize Prisma in your project. Open your terminal, navigate to the root directory of your project, and execute the following command. This will set up the basic Prisma configuration files.
npx prisma init
For this tutorial, we'll be using Neon Postgres as our database. To configure the connection, create a .env file in the root directory of your project and add the following variable, making sure to replace the placeholder connection string with your actual Neon Postgres connection string.
// .env
DATABASE_URL="postgresql://alex:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/dbname?sslmode=require"
Next, we'll populate our Prisma schema with the tables required for BetterAuth. This includes tables for users, authentication tokens, and potentially other data depending on the features you want to implement.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id
name String
email String
emailVerified Boolean
image String?
createdAt DateTime?
updatedAt DateTime?
Session Session[]
Account Account[]
role String?
banned Boolean?
banReason String?
banExpires DateTime?
firstName String?
lastName String?
phone String?
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime?
updatedAt DateTime?
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
impersonatedBy String?
@@unique([token])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime?
updatedAt DateTime?
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
To finalize the setup, execute the following command. This will push your schema to the database and generate the Prisma Client, which you'll use to interact with your database.
npx prisma db push
3. Set Up Better Auth
Let's start by adding Better Auth to your project:
npm install better-auth
Add the following variables to your .env file. Crucially, replace "BetterAuth Secret" with a strong, randomly generated secret. Use openssl or the secret generator in the BetterAuth documentation to create a secure secret. Do not use the placeholder value in a production environment!
// .env
BETTER_AUTH_SECRET="BetterAuth Secret"
BETTER_AUTH_URL="http://localhost:5173" #Default vite dev server url:port
In this example, we'll set up Google social authentication, allowing users to log in with their Google accounts. We'll use the Prisma adapter to conveniently manage user data within our Prisma database.
To organize our authentication logic, let's create a utils/auth folder inside the app directory. Within this folder, create a file named server.ts. This file will contain all the server-side configuration code for BetterAuth.
// app/utils/auth/server.ts
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export const auth = betterAuth({
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
}
},
database: prismaAdapter(prisma, {
provider: 'postgresql'
})
})
To enable Google authentication with BetterAuth, we need to perform a few configuration steps, as outlined in the BetterAuth documentation. Let's get started!
To use Google as a social provider, you need to get your Google credentials. You can get them by creating a new project in the Google Cloud Console.
In the Google Cloud Console > Credentials > Authorized redirect URIs, make sure to set the redirect URL to http://localhost:5173/api/auth/callback/google for local development. For production, make sure to set the redirect URL as your application domain, e.g. https://example.com/api/auth/callback/google. If you change the base path of the auth routes, you should update the redirect URL accordingly.
Now that you have your Google Client ID and Google Client Secret, let's add them to our .env file. Be sure to replace the placeholder strings in the code below with your actual values
// .env
GOOGLE_CLIENT_ID="GOOGLE_CLIENT_ID"
GOOGLE_CLIENT_SECRET="GOOGLE_CLIENT_SECRET"
Let's create the BetterAuth client. This client will provide the methods we need to sign up, sign in, and sign out users. Create a client.ts file within the app/utils/auth directory to house this client.
// app/utils/auth/client.ts
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient()
To complete the BetterAuth setup, we need to mount the authentication handler to an API route, which React Router v7 refers to as a "Resource Route."
First, clear out the app/routes directory by removing all files within it. Then, create a new folder named api inside the app/routes directory. Finally, create an auth.ts file inside the newly created app/routes/api directory.
// app/routes/api/auth.ts
import { auth } from '@/utils/auth/server'
import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router'
export async function loader({ request }: LoaderFunctionArgs) {
return auth.handler(request)
}
export async function action({ request }: ActionFunctionArgs) {
return auth.handler(request)
}
3. Setup tRPC v11
To integrate a tRPC backend with React Router v7 and BetterAuth, we need to install a few additional packages. Run the following commands in your terminal to install them
npm i @tanstack/react-query @trpc/client@next @trpc/server@next @trpc/tanstack-react-query superjson zod
Let's begin setting up tRPC. Inside the app directory, create a server directory to house all our tRPC routers and procedures. Within app/server, create a db.ts file. In this file, we'll instantiate a Prisma client, which we'll use to query our database.
// app/server/db.ts
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined
}
export const db = globalForPrisma.prisma ?? prismaClientSingleton()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
Inside the app/server directory, create a trpc.ts file. This file will contain all our tRPC backend configuration. Be sure to read the comments within the code carefully, as they explain each step involved in configuring tRPC.
// app/server/trpc.ts
import superjson from 'superjson'
import { ZodError } from 'zod'
import { initTRPC, TRPCError } from '@trpc/server'
import { db } from '@/server/db'
import { auth } from '@/utils/auth/server'
// Create the tRPC context, which includes the database client and the potentially authenticated user. This will provide convenient access to both within our tRPC procedures.
export const createTRPCContext = async (opts: { headers: Headers }) => {
const authSession = await auth.api.getSession({
headers: opts.headers
})
const source = opts.headers.get('x-trpc-source') ?? 'unknown'
console.log('>>> tRPC Request from', source, 'by', authSession?.user.email)
return {
db,
user: authSession?.user
}
}
type Context = Awaited<ReturnType<typeof createTRPCContext>>
// Initialize tRPC with the context we just created and the SuperJSON transformer.
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
}
})
})
// Create a caller factory for making server-side tRPC calls from loaders or actions.
export const createCallerFactory = t.createCallerFactory
// Utility for creating a tRPC router
export const createTRPCRouter = t.router
// Utility for a public procedure (doesn't require an autheticated user)
export const publicProcedure = t.procedure
// Create a utility function for protected tRPC procedures that require an authenticated user.
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user?.id) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({
ctx: {
user: ctx.user
}
})
})
We'll now define our first tRPC router and a public procedure. To organize our routers, create a new directory called router inside the app/server directory. Inside app/server/routers, create a file named greeting.ts. This file will contain the code for our router.
Within this router, we'll define two procedures: a public procedure called hello, which returns the string "Hello, world!", and a protected procedure called user, which retrieves and returns the currently authenticated user's data from the database.
// app/server/routers/greeting.ts
import type { TRPCRouterRecord } from '@trpc/server'
import { protectedProcedure, publicProcedure } from '@/server/trpc'
export const postRouter = {
hello: publicProcedure.query(() => {
return 'hello world'
}),
user: protectedProcedure.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findFirst({
where: {
id: ctx.user?.id
}
})
return user
})
} satisfies TRPCRouterRecord
Now, let's create our main router. Inside the app/server directory, create a main.ts file. This main router will serve to merge all our individual routers into a single endpoint.
// app/server/main.ts
import { createTRPCRouter } from './trpc'
import { greetingRouter } from './routers/greeting'
export const appRouter = createTRPCRouter({
greeting: greetingRouter
})
export type AppRouter = typeof appRouter
The next step is to mount the tRPC handler to an API route, which React Router v7 refers to as a "Resource Route." Inside the app/routes/api directory, create a file named trpc.ts.
// app/routes/api/trpc.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/main'
import { createTRPCContext } from '@/server/trpc'
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'
export const loader = async (args: LoaderFunctionArgs) => {
return handleRequest(args)
}
export const action = async (args: ActionFunctionArgs) => {
return handleRequest(args)
}
function handleRequest(args: LoaderFunctionArgs | ActionFunctionArgs) {
return fetchRequestHandler({
endpoint: '/api/trpc',
req: args.request,
router: appRouter,
createContext: () =>
createTRPCContext({
headers: args.request.headers
})
})
}
Next, let's integrate tRPC with TanStack Query and define the provider that will allow us to call our procedures directly from our client-side React components using TanStack Query. Inside the app/utils directory, create a new directory called trpc. Within app/utils/trpc, create a file named react.tsx.
// app/utils/trpc/react.tsx
import SuperJSON from 'superjson'
import { useState } from 'react'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client'
import { createTRPCContext } from '@trpc/tanstack-react-query'
import type { AppRouter } from '@/server/main'
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000
}
}
})
}
let browserQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient()
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
const getBaseUrl = () => {
if (typeof window !== 'undefined') return window.location.origin
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return `http://localhost:${process.env.PORT ?? 3000}`
}
const links = [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === 'development' ||
(op.direction === 'down' && op.result instanceof Error)
}),
httpBatchLink({
transformer: SuperJSON,
url: getBaseUrl() + '/api/trpc',
headers() {
const headers = new Headers()
headers.set('x-trpc-source', 'react')
return headers
}
})
]
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>()
export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links
})
)
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
)
}
With our provider component ready, we need to make it available to our entire application. To do this, we'll wrap our main App component with the provider in the root.tsx file. Modify root.tsx as follows:
// app/root.tsx
-export default function App() {
- return <Outlet />;
-}
+export default function App() {
+ return (
+ <TRPCReactProvider>
+ <Outlet />
+ </TRPCReactProvider>
+ )
+}
Next, let's configure our tRPC caller to enable server-side calls to our procedures from loaders and actions. Inside the app/utils/trpc directory, create a file called server.ts.
// app/utils/trpc/server.ts
import { createCallerFactory, createTRPCContext } from '@/server/trpc'
import { appRouter } from '@/server/root'
import { type LoaderFunctionArgs } from 'react-router'
const createContext = (opts: { headers: Headers }) => {
const headers = new Headers(opts.headers)
headers.set('x-trpc-source', 'server-loader')
return createTRPCContext({
headers
})
}
const createCaller = createCallerFactory(appRouter)
export const caller = async (loaderArgs: LoaderFunctionArgs) =>
createCaller(await createContext({ headers: loaderArgs.request.headers }))
5. Define React Router Routes
With tRPC fully configured, we can now define all our routes in React Router. This includes the Resource/API Routes for BetterAuth and tRPC, as well as the regular page routes for our application. We'll modify the app/routes.ts file to configure these routes.
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes'
export default [
route('/api/auth/*', 'routes/api/auth.ts'),
route('/api/trpc/*', 'routes/api/trpc.ts'),
index('routes/home.tsx'),
route('/user', 'routes/user.tsx'),
route('/signin', 'routes/signin.tsx')
] satisfies RouteConfig
Now that our routes are defined, let's create our home page, which React Router refers to as the "index route." To do this, create a file named home.tsx inside the app/routes directory. Within this home.tsx file, we'll call the greeting.hello procedure to display a greeting.
We will be using TansTack Query on the client side to fetch data.
// app/routes/home.tsx
import { useTRPC } from '@/utils/trpc/react'
import { useQuery } from '@tanstack/react-query'
export default function Home() {
const trpc = useTRPC()
const { data: hello } = useQuery(trpc.greeting.hello.queryOptions())
return (
<div className='flex flex-col items-center justify-center min-h-screen min-w-screen'>
{hello}
</div>
)
}
Next, we need to create our sign-in page. To do this, create a file named signin.tsx inside the app/routes directory. We'll be using our BetterAuth client to handle the sign-in functionality.
// app/routes/signin.tsx
import { authClient } from '@/utils/auth/client'
export default function SignIn() {
const signIn = async () => {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/user'
})
}
return (
<div className='px-6 sm:px-0 max-w-sm min-h-screen mx-auto flex items-center justify-center'>
<button
type='button'
className='text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-between mr-2 mb-2'
onClick={() => signIn()}
>
<svg
className='mr-2 -ml-1 w-4 h-4'
aria-hidden='true'
focusable='false'
data-prefix='fab'
data-icon='google'
role='img'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 488 512'
>
<path
fill='currentColor'
d='M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z'
></path>
</svg>
Sign up with Google<div></div>
</button>
</div>
)
}
With user sign-in implemented, let's create the user page, which will display personalized information. To ensure that only authenticated users can access this page, we'll perform an authentication check on the server side using a loader. If no user is authenticated or if an error occurs during authentication, we'll redirect them to the home page. Otherwise, we'll display a personalized greeting. Create a file named user.tsx inside the app/routes directory to define this page.
// app/routes/user.tsx
import type { Route } from './+types/user'
import { caller } from '@/utils/trpc/server'
import { redirect } from 'react-router'
export async function loader(loaderArgs: Route.LoaderArgs) {
const api = await caller(loaderArgs)
try {
const user = await api.greeting.user()
if (!user) {
return redirect('/')
}
return user
} catch (error) {
return redirect('/')
}
}
export default function Home({ loaderData: user }: Route.ComponentProps) {
return (
<div className='flex flex-col items-center justify-center min-h-screen min-w-screen'>
Hello! {user?.name}
</div>
)
}
6. Conclusion
This is merely a basic starting guide, if you're working on a project destined for production, make sure to check the docs for all the tools we used.
If you have any questions or need help, Feel free to DM me on Twitter/X
Cheers!
Top comments (1)
I would go with vite, wouter, react query, and python flask API backend.
Trpc locks you into node
React router churns too fast