Better-auth with vite (frontend) and express (backend) #1281

Closed
opened 2026-03-13 08:31:05 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @codewithkin on GitHub (May 29, 2025).

Hey guys, so I have an app that uses vite and express however I am getting a small error

When a user attempts to login via magic link from the frontend (running on port 3000 locally), the magic link is sent but it contains a link to:

http://localhost:3000/api/auth/magic-link/verify?token=wmjYeSdMubbFanHElgOQPwAaGRIbNfdV&callbackURL=/dashboard

Please note that at this point the value of BETTER_AUTH_URL is http://localhost:3000

It seems better-auth expects my frontend to verify the magic link somehow, I tried changing BETTER_AUTH_URL (server-side) to http://localhost:8080, it now verifies the magic link successfully however it now redirects me to localhost:8080/dashboard (this page is on the frontend, however it treats it as though the backend and frontend are on the same domain (in....for example, a NextJS architecture))

Here is the relevant code:
frontend auth-client.ts:
import {magicLinkClient} from "better-auth/client/plugins";
import {createAuthClient} from "better-auth/react";
export const authClient = createAuthClient({
baseURL:
import.meta.env.MODE === "production"
? "https://api.botworld.pro"
: "http://localhost:8080",
plugins: [magicLinkClient()],
});

Frontend mutations for signing in:
const signInWithEmail = useMutation({
mutationKey: ["signInWithEmail"],
mutationFn: async () => {
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/dashboard",
});

  if (error) {
    return toast.error(
      "An error occured while signing you in...please try again later",
    );
  }

  toast.success("Success ! Please check your email for a sign in link");

  setEmailSent(true);
},
onError: () => {
  toast.error("Failed to send email. Please try again.");
},

});

const signInWithGoogle = useMutation({
mutationKey: ["google-sign-in"],
mutationFn: async () => {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
},
onError: () => {
toast.error("Google sign-in failed. Try again.");
},
});

Backend auth.ts:
import {betterAuth} from "better-auth";
import {prismaAdapter} from "better-auth/adapters/prisma";
import {magicLink} from "better-auth/plugins";
import {sendMagicLinkEmail} from "./email/sendMagicLink";
import {PrismaClient} from "../../prisma/generated/prisma";

export const prisma = new PrismaClient();

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
plugins: [
magicLink({
sendMagicLink: sendMagicLinkEmail,
}),
],
trustedOrigins: [
"http://localhost:8080",
"http://localhost:3000",
"https://app.botworld.pro",
"https://api.botworld.pro",
],
});

Backend server.ts:
`
import express, {Request, Response} from "express";
import {createServer} from "http";
import {Server as IoServer} from "socket.io";
import {createWhatsAppClient} from "./manager/whatsapp-manager";
import cors from "cors";
import morgan from "morgan";

// Better-auth
import {toNodeHandler} from "better-auth/node";
import {auth} from "./lib/auth";
import {setBotConfig} from "./functions/db/setBotConfig";
import {config} from "dotenv";

config();

const app = express();
const httpServer = createServer(app);

// Add request logging
app.use(morgan("combined"));

// Configure CORS middleware
app.use(
cors({
origin: ["http://localhost:3000", "https://app.botworld.pro"],
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
})
);

// Add better-auth endpoints
app.all("/api/auth/*splat", toNodeHandler(auth));

const io = new IoServer(httpServer, {
cors: {
origin: [
"http://localhost:3000",
"https://botworld.pro",
"https://app.botworld.pro",
],
methods: ["GET", "POST"],
credentials: true,
},
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
transports: ["websocket", "polling"],
});

io.on("connection", (socket) => {
const botId = socket.handshake.auth.botId as string;
const userId = socket.handshake.auth.userId as string;
console.log(New connection for bot: ${botId});

socket.on("init", async () => {
try {
console.log("Initializing bot: ", botId);
const client = await createWhatsAppClient(botId, socket);
socket.emit("status", "Initializing WhatsApp connection...");
} catch (error) {
console.error(Init error for bot ${botId}:, error);
socket.emit("error", "Failed to initialize client");
}
});

socket.on(
"authenticate",
async ({botId: clientBotId, userId: clientUserId, assistantId}) => {
try {
console.log("User id: ", clientUserId);
console.log("Assistant id: ", assistantId);

    if (!clientUserId || !assistantId) {
      console.error("Both userId and assistantId are required");
      socket.emit("error", "Both userId and assistantId are required");
      return;
    }

    console.log("Setting values: ", {
      botId: clientBotId,
      userId: clientUserId,
      assistantId,
    });

    await setBotConfig(clientBotId, "userId", clientUserId);
    await setBotConfig(clientBotId, "assistantId", assistantId);

    console.log(`Stored IDs for bot ${clientBotId}`);
    console.log(
      `User ${clientUserId} authenticated for bot ${clientBotId}`
    );
  } catch (error) {
    console.error("Authentication storage failed:", error);
    socket.emit("error", "Failed to store authentication data");
  }
}

);

socket.on("disconnect", (reason) => {
console.log(Disconnected (${reason}) from bot: ${botId});
});
});

const PORT = process.env.WHATSAPP_SERVER_PORT || 3001;
httpServer.listen(PORT, () => {
console.log(WhatsApp server running on port ${PORT});
});

`

Originally created by @codewithkin on GitHub (May 29, 2025). Hey guys, so I have an app that uses vite and express however I am getting a small error When a user attempts to login via magic link from the frontend (running on port 3000 locally), the magic link is sent but it contains a link to: ` http://localhost:3000/api/auth/magic-link/verify?token=wmjYeSdMubbFanHElgOQPwAaGRIbNfdV&callbackURL=/dashboard ` Please note that at this point the value of BETTER_AUTH_URL is http://localhost:3000 It seems better-auth expects my frontend to verify the magic link somehow, I tried changing BETTER_AUTH_URL (server-side) to http://localhost:8080, it now verifies the magic link successfully however it now redirects me to localhost:8080/dashboard (this page is on the frontend, however it treats it as though the backend and frontend are on the same domain (in....for example, a NextJS architecture)) Here is the relevant code: frontend auth-client.ts: import {magicLinkClient} from "better-auth/client/plugins"; import {createAuthClient} from "better-auth/react"; export const authClient = createAuthClient({ baseURL: import.meta.env.MODE === "production" ? "https://api.botworld.pro" : "http://localhost:8080", plugins: [magicLinkClient()], }); Frontend mutations for signing in: const signInWithEmail = useMutation({ mutationKey: ["signInWithEmail"], mutationFn: async () => { const { error } = await authClient.signIn.magicLink({ email, callbackURL: "/dashboard", }); if (error) { return toast.error( "An error occured while signing you in...please try again later", ); } toast.success("Success ! Please check your email for a sign in link"); setEmailSent(true); }, onError: () => { toast.error("Failed to send email. Please try again."); }, }); const signInWithGoogle = useMutation({ mutationKey: ["google-sign-in"], mutationFn: async () => { await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard", }); }, onError: () => { toast.error("Google sign-in failed. Try again."); }, }); Backend auth.ts: import {betterAuth} from "better-auth"; import {prismaAdapter} from "better-auth/adapters/prisma"; import {magicLink} from "better-auth/plugins"; import {sendMagicLinkEmail} from "./email/sendMagicLink"; import {PrismaClient} from "../../prisma/generated/prisma"; export const prisma = new PrismaClient(); export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", }), socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, plugins: [ magicLink({ sendMagicLink: sendMagicLinkEmail, }), ], trustedOrigins: [ "http://localhost:8080", "http://localhost:3000", "https://app.botworld.pro", "https://api.botworld.pro", ], }); Backend server.ts: ` import express, {Request, Response} from "express"; import {createServer} from "http"; import {Server as IoServer} from "socket.io"; import {createWhatsAppClient} from "./manager/whatsapp-manager"; import cors from "cors"; import morgan from "morgan"; // Better-auth import {toNodeHandler} from "better-auth/node"; import {auth} from "./lib/auth"; import {setBotConfig} from "./functions/db/setBotConfig"; import {config} from "dotenv"; config(); const app = express(); const httpServer = createServer(app); // Add request logging app.use(morgan("combined")); // Configure CORS middleware app.use( cors({ origin: ["http://localhost:3000", "https://app.botworld.pro"], methods: ["GET", "POST", "PUT", "DELETE"], credentials: true, }) ); // Add better-auth endpoints app.all("/api/auth/*splat", toNodeHandler(auth)); const io = new IoServer(httpServer, { cors: { origin: [ "http://localhost:3000", "https://botworld.pro", "https://app.botworld.pro", ], methods: ["GET", "POST"], credentials: true, }, connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000, skipMiddlewares: true, }, transports: ["websocket", "polling"], }); io.on("connection", (socket) => { const botId = socket.handshake.auth.botId as string; const userId = socket.handshake.auth.userId as string; console.log(`New connection for bot: ${botId}`); socket.on("init", async () => { try { console.log("Initializing bot: ", botId); const client = await createWhatsAppClient(botId, socket); socket.emit("status", "Initializing WhatsApp connection..."); } catch (error) { console.error(`Init error for bot ${botId}:`, error); socket.emit("error", "Failed to initialize client"); } }); socket.on( "authenticate", async ({botId: clientBotId, userId: clientUserId, assistantId}) => { try { console.log("User id: ", clientUserId); console.log("Assistant id: ", assistantId); if (!clientUserId || !assistantId) { console.error("Both userId and assistantId are required"); socket.emit("error", "Both userId and assistantId are required"); return; } console.log("Setting values: ", { botId: clientBotId, userId: clientUserId, assistantId, }); await setBotConfig(clientBotId, "userId", clientUserId); await setBotConfig(clientBotId, "assistantId", assistantId); console.log(`Stored IDs for bot ${clientBotId}`); console.log( `User ${clientUserId} authenticated for bot ${clientBotId}` ); } catch (error) { console.error("Authentication storage failed:", error); socket.emit("error", "Failed to store authentication data"); } } ); socket.on("disconnect", (reason) => { console.log(`Disconnected (${reason}) from bot: ${botId}`); }); }); const PORT = process.env.WHATSAPP_SERVER_PORT || 3001; httpServer.listen(PORT, () => { console.log(`WhatsApp server running on port ${PORT}`); }); `
Author
Owner

@Kinfe123 commented on GitHub (May 29, 2025):

Set BETTER_AUTH_URL to Your Backend and add callback to your frontend. it should work!

@Kinfe123 commented on GitHub (May 29, 2025): Set BETTER_AUTH_URL to Your Backend and add callback to your frontend. it should work!
Author
Owner

@codewithkin commented on GitHub (May 31, 2025):

Is there a guide for this ? If I set my callback url to const { error } = await authClient.signIn.magicLink({
email,
callbackURL: ${import.meta.env.VITE_APP_URL}/dashboard,
});

It signs me in successfully however when I try to access my session from the backend via:
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});

  console.log("Session data:", session);

I get:
Session data: null

@codewithkin commented on GitHub (May 31, 2025): Is there a guide for this ? If I set my callback url to const { error } = await authClient.signIn.magicLink({ email, callbackURL: `${import.meta.env.VITE_APP_URL}/dashboard`, }); It signs me in successfully however when I try to access my session from the backend via: const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers), }); console.log("Session data:", session); I get: Session data: null
Author
Owner

@Kinfe123 commented on GitHub (May 31, 2025):

can you please check if the cookie sent along with the req ?

@Kinfe123 commented on GitHub (May 31, 2025): can you please check if the cookie sent along with the req ?
Author
Owner

@codewithkin commented on GitHub (Jun 1, 2025):

(1) Is the logic for callBackURL correct ? (Should I add the baseUrl to the callbackUrl)

(2) How do I check if the cookie is sent ?

@codewithkin commented on GitHub (Jun 1, 2025): (1) Is the logic for callBackURL correct ? (Should I add the baseUrl to the callbackUrl) (2) How do I check if the cookie is sent ?
Author
Owner

@codewithkin commented on GitHub (Jun 1, 2025):

Because currently I think what's happening is because I added the baseUrl to the callbackUrl, it is redirecting me to the correct page which makes me think auth is working

But the absence of the session cookie means that auth is not actually working

@codewithkin commented on GitHub (Jun 1, 2025): Because currently I think what's happening is because I added the baseUrl to the callbackUrl, it is redirecting me to the correct page which makes me think auth is working But the absence of the session cookie means that auth is not actually working
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1281