How to Integrate a Standalone Hono Backend with Multiple Next.js Frontends


Status: Ready Creation Date: May 25, 2025
Unlock the power of decoupled architecture! This beginner-friendly guide shows you how to build a Next.js frontend with a dedicated Hono backend for authentication. Learn how to separate your website's brains from its beauty, enabling cross-domain authentication, multi-platform support, and a more scalable, maintainable application. Perfect for developers of all levels!
Hey everyone! Ever wanted to build a website where the frontend (what users see) and the backend (the behind-the-scenes logic) are totally separate? This setup is called a "decoupled architecture," and it's awesome for building scalable and maintainable web applications. The source code is available at Github
In this tutorial, we'll learn how to create a system where our Next.js frontends (the user interface) talk to a dedicated Hono backend (handling authentication and data). This means you can use the same backend for multiple frontends – super useful if you have different versions of your app or want to build mobile apps later! We'll use Better Auth to handle user authentication (sign-up, login, etc.) across these separate applications. Don't worry if you're new to this; we'll take it slow and explain everything step by step.
Here's a simple diagram of what we're going to build:
Why is this useful? Imagine you want to build a web app and a mobile app. With this setup, you can use the same backend for both, saving you tons of time and effort! It's also great for scenarios where you need secure authentication across different domains (website names).
Ready? Let's dive in! All the code for this project is available on GitHub.
Setting Up the Hono Backend
First, we'll create our backend using Hono, a lightweight web framework for JavaScript. Think of it as the engine that powers our authentication and data management.
- Create a New Hono Project:
We'll use bun for this. If you don't have bun you can replace all bun commands with npm
Open your terminal (the command line) and run:
bun create hono@latest # Create a new Hono project
cd <your-project-name> # Go into the project folder
This sets up the basic structure for our Hono backend.
- Install Dependencies:
We're going to use Prisma, a tool that helps us interact with our database. Let's install the necessary packages:
bun add better-auth @prisma/client # Add Better Auth and Prisma
bun add prisma --save-dev # Add Prisma CLI as a development dependency
These commands add the libraries we need for authentication and database access.
- Initialize Prisma
Our backend needs a database to store user information. We'll use Prisma to connect to it.
bun prisma init # Initialize Prisma
This creates a prisma folder with a schema.prisma file and a .env file. The .env file will contain your database connection string.
Open the .env file and update the DATABASE_URL to point to your PostgreSQL database (e.g., Neon, a local PostgreSQL instance, or any other provider). You'll also want to set a port for our backend to run on:
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
PORT=5000 # Important to deploy backend at a different port for development
Replace the example database URL with your actual database credentials.
Now, generate the Prisma client:
bun prisma generate
This creates a generated folder inside the src directory.
- Initialize the Prisma Client:
Let's create a way to access our database throughout our backend code. Create a file called prismaClient.ts
(e.g., in a prisma or lib folder)
// src/lib/prismaClient.ts
import { PrismaClient } from "../generated/prisma"; // Import the generated Prisma client
declare global {
var prisma: PrismaClient;
}
// Prevent multiple instances of Prisma Client in development
// biome-ignore lint/suspicious/noRedeclare: This is needed here
const prisma = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalThis.prisma = prisma;
}
export default prisma;
This code creates a global instance of the Prisma client, so we can easily use it to interact with the database.
- Configure Better Auth:
Better Auth needs some configuration to work properly. Add these environment variables to your .env file:
BETTER_AUTH_SECRET=F12yEJwDlj3z5M8cuQ6biAhWe3cpjNkJ # A random secret string for security
BETTER_AUTH_URL=http://localhost:5000 # The URL of our backend server
Important: Replace the example secret with a strong, randomly generated string. This is crucial for security!
- Setup
auth.ts
This file is where we configure how authentication works in our app. We'll enable sign-in with email and password.
Create a file named auth.ts
in one of these locations:
Project root
lib/
folderutils/
folder
You can also nest any of these folders under src/
, app/
or server/
folder. (e.g. src/lib/auth.ts
, app/lib/auth.ts
).
And in this file, import Better Auth and create your auth instance. Make sure to export the auth instance with the variable name auth
or as a default
export.
import { betterAuth } from "better-auth";
// Adapter to use prisma orm
import { prismaAdapter } from "better-auth/adapters/prisma";
// The prisma client we creater to control the db
import prisma from "./prismaClient";
export const auth = betterAuth({
// Provide the database adapter prisma here
database: prismaAdapter(prisma, {
provider: "postgresql", // specify the db here
}),
// Authentication methods
emailAndPassword: {
enabled: true,
},
//To setup cross domain cookies
advanced: {
// This will enable cookie passing and checking across domains
defaultCookieAttributes: {
sameSite: "none",
httpOnly: true,
secure: true,
partitioned: true,
},
},
// trustedOrigins
trustedOrigins: ["http://localhost:3000"],
// Enable cookie cache to avoid hitting db to get session each time
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // in seconds (5min)
},
},
// Since we are building multiple frontends for types of user we add special info to the core user field
user: {
additionalFields: {
// I take role as the differentiator here , each role has access to different front end application
role: {
type: "string",
enum: ["USER","ADMIN","SUPERADMIN"],
defaultValue:"USER",
required: false // Check the note below to know why I kept this false
},
},
},
});
// Extract the session and user type inorder to use them in our routes
export type AuthType = {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
};
Explanation:
betterAuth
: The main function from the Better Auth library.prismaAdapter
: Tells Better Auth how to use Prisma to store user data in the database.emailAndPassword
: Enables sign-in using email and password.trustedOrigins
: Specifies the domains that are allowed to make requests to our backend (important for security).advanced: Here we are setting configurations for cross domain cookies, these will make sure to send cookies across different ports or domains
After setting up the auth.ts
file, generate the types and models to store them in your database using the Better Auth CLI:
bun x @better-auth/cli@latest generate
Confirm overwriting the schema.prisma
file by pressing y.
The file ./prisma/schema.prisma already exists. Do you want to overwrite the schema to the file? » (y/N)
This command generates the database schema and necessary TypeScript types for Better Auth.
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
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
role String?
sessions Session[]
accounts Account[]
@@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)
@@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")
}
You can notice that even though we gave the enum values for the role field in the user model , its here shown as an optional string. If you want you can change it to an enum model here but keep it optional like this
enum Role {
USER
ADMIN
SUPERADMIN
}
model User {
id String @id
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
role Role? // Changed the value to Role? from String?
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
Since we updated the schema , lets migrate the change to db
bun prisma migrate dev
Since we changed the role data to an enum with Role in prisma schema lets also update the auth.ts
so that we maintain a single source of truth for the Role type
// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { Role } from "../generated/prisma"; // The generated type for the enum role
...
export const auth = betterAuth({
...
user: {
additionalFields: {
role: {
type: "string",
enum: Object.keys(Role),
defaultValue: Role.USER,
required: false, // Check the note below to know why I kept this false
},
},
},
});
...
- Mount the API Handler for Authentication:
We need to tell Hono how to handle authentication requests. Create a new file /routes/auth.ts
:
// src/routes/auth.ts
import { Hono } from "hono";
import { auth, type AuthType } from "../lib/auth";
// The hono router
const router = new Hono<{ Variables: AuthType }>({
strict: false,
});
// All GET POST requests coming to /auth/** with be handled by this router
// We bind the auth handler to this router on all GET POST requests.
router.on(["POST", "GET"], "auth/**", (c) => {
return auth.handler(c.req.raw); //passing the entire request to the handler from auth
});
export default router;
This code creates a Hono router that handles all requests to /auth/**
using Better Auth.
Now, mount this router to your main index.ts
file:
// index.ts
import { Hono } from "hono";
import authRouter from "./routes/auth";
import type { AuthType } from "./lib/auth";
import {cors} from "hono/cors"
const app = new Hono<{ Variables: AuthType }>({
strict: false,
});
// This is to allow api request from our frontend
app.use(
"*", // Right now i am applying this cors to all routes in the app
cors({
origin: ["<http://localhost:3000>"],
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["GET", "POST", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
})
);
// All the routers from /routes import and add to this list eg: [authRouter,userRouter,paymentsRouter]
const api_routes = [authRouter];
// All the routers in the api_routes list will be mounted at /api route of hono
for (const route of api_routes) {
app.basePath("/api").route("/", route);
}
app.route("/api", authRouter);
app.get("/", (c) => {
return c.text("Hello Hono!");
});
export default app;
Explanation:
app.route("/api", authRouter)
: Mounts the authentication router at the /api endpoint. So, all authentication routes will start with /api/auth.cors
: added to allow requests from front end and sending cookies with it.
To add more routes to /api
, simply import them and add them to the api_routes
array.
That's it for the backend setup! Now, let's move on to the Next.js frontend.
Setting Up the Next.js Frontend
Next, we'll create our Next.js frontend, where users will interact with our application. Remember where are two ways to make request from a Nextjs application
from Server side
from Client side
For this tutorial I am going to make the login and register request from Client side because that way the nextjs handles saving cookies in the browser, makes our task easier. so lets setup nextjs first.
- Create the Next.js App:
Open your terminal and run:
bun create next-app@latest
- Install Dependencies:
bun add better-auth
Create an Auth Client:
Just like with Prisma, we need a client to communicate with our Better Auth backend. Create a new file
/lib/auth.ts
// /src/lib/auth.ts import { inferAdditionalFields } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: `${process.env.BACKEND_URL}/api/auth`, plugins: [ inferAdditionalFields({ user: { role: { type: "string", required: false, }, }, }), ], });
Explanation:
createAuthClient
: Creates a client that allows us to call Better Auth functions from our frontend.baseURL
: The URL of our Better Auth backend API. Make sure to set the BACKEND_URL environment variable in your Next.js project!
Registering and Logging In
Lets Register new account and login this happens from client side because then nextjs automatically parse the cookies and sets it for us. Well you may ask cant we just parse the cookies ourself and do this from server, well you could but i have not ventured that far in to test that theory out . Lets start
For registration, Better Auth requires the following input (for email and password signup):
{
image: string, // optional
name: string,
email: string,
password: string
}
Collect this data from the user using a form and call the signUp method:
async function onSubmit(values: z.infer<typeof formSchema>) {
// This is calling the signUp with email method
const { data: session, error } = await authClient.signUp.email({
email: values.email,
password: values.password,
name: `${values.firstName} ${values.lastName}`,
})
if (error) {
// Sign in was unsuccessfull
console.error(error)
return
}
// If the register was succesful then the cookies would have been already set you would have already logged in
router.replace('/profile')
}
Protecting Pages
We want to make sure only logged-in users can access certain pages. Here's how to do that using Better Auth:
- Server Side Page:
import {headers} from "next/headers";
import { redirect } from 'next/navigation';
import { authClient } from "@/lib/auth";
export default async function ProfilePage() {
// Since the page is a server rendered we need the headers to be passed with getSession
const { data: session, error } = await authClient.getSession({
fetchOptions: {
headers: await headers()
}
})
// if session is not found redirect the user to login page
if (error) {
redirect('/login')
}
return (
<div className={'h-screen w-screen flex items-center justify-center'}>'
<Card>
<CardHeader>
<CardTitle className={'flex items-center justify-between'}>
<p>This is your session</p>
<SignOut />
</CardTitle>
</CardHeader>
<CardContent>
<pre>
{JSON.stringify(session, null, 2)}
</pre>
</CardContent>
</Card>
</div>
);
}
Explanation:
authClient.getSession()
: Checks if the user has an active session by sending cookies with headers.redirect('/login')
: If there's no session, redirect the user to the login page.
- Client-Side Page:
"use client"
import { authClient } from "@/lib/auth";
import { useRouter } from "next/navigation";
import { Loader2 as LoaderIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import SignOut from "@/components/sign-out";
export default function ClientProfilePage() {
const router = useRouter()
// Here we use the useSession hook to get the session
const { data: session, error, isPending } = authClient.useSession();
// If the loading is over and we get error then the user is not logged in
if (!isPending && error) {
router.push('/login')
}
return (
<div className={'h-screen w-screen flex items-center justify-center'}>'
{
isPending ?
<div className={'flex flex-col gap-2 items-center'}>
<LoaderIcon className={'animate-spin'} />
<p>Loading your session</p>
</div> :
<Card>
<CardHeader>
<CardTitle className={'flex items-center justify-between'}>
<p>This is your session</p>
<SignOut />
</CardTitle>
</CardHeader>
<CardContent>
<pre>
{JSON.stringify(session, null, 2)}
</pre>
</CardContent>
</Card>
}
</div>
);
}
Explanation:
authClient.useSession(): A React Hook that provides the current session status.
isPending: A boolean that indicates if the session is still being loaded.
error: An error object if there was a problem fetching the session.
Making Authenticated API calls ( Protect API calls )
Now lets see how we will make authenticated api calls to the hono backend
To define the server action create a new file in src/actions/message.ts
and use the following code to make the request to the backend
- Using a Server Action:
"use server";
// We need to get the cookies from the browser
import { cookies } from "next/headers";
export async function getProtectedMessage() {
const cookie = await cookies();
// you can use an env to store the backend url and use template literal to make the route
const res = await fetch("<http://localhost:5000/api/message>", {
method: "POST",
// pass the cookie as the header
headers: {
cookie: cookie.toString(),
},
});
if (!res.ok) {
console.log("Response was not successfull");
console.log("Error : ", res.status);
console.log("ErrorMessage : ", res.statusText);
return {
data: {},
error: "Response was not successfull",
};
}
const resjson = await res.json();
return {
data: resjson,
error: null,
};
}
When you are making the api request from a client component like not using a server action then you
We are making the request to this endpoint in backend
/src/routes/message.ts
import { Hono } from "hono";
import { auth, type AuthType } from "../lib/auth";
const router = new Hono<{ Variables: AuthType }>({
strict: false,
}).basePath("/message");
router.get("/", (c) => {
return c.json({
data: {
message: "Message route make post request with auth",
},
});
});
router.post("/", async (c) => {
// Checking the session by passing the headers from the request to betterauth to verify
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
// No session and no user means its an unauthorised request . so early return
if (!session || !session.user) {
return c.json({
data: {
message: "Not Authenticated",
},
});
}
return c.json({
data: {
message: "Message route make post request with auth",
},
});
});
export default router;
dont forget to import the message route in our
index.ts
import { Hono } from "hono";
import messageRouter from "./routes/message"; //import here
// Rest of the code
const api_routes = [authRouter, messageRouter]; // Routes that needs to be added /api as their basePath
// Iterating through the routes and setting '/api' as their base path
for (const route of api_routes) {
app.basePath("/api").route("/", route);
}
// Rest of the code
export default app;
- Directly from the Client:
"use client"
import { getProtectedMessage } from "@/app/actions/message";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader } from "lucide-react";
import { useState } from "react";
export default function MessagePage() {
const [ loading, setLoading ] = useState(false);
const [ message, setMessage ] = useState('');
// This function makes the fetch call directly to the hono server
const getMessage = async () => {
setLoading(true);
const res = await fetch('http://localhost:5000/api/message', {
method: "POST",
credentials: "include" // include cookies with the request
})
if (!res.ok) {
console.log(res.statusText)
setLoading(false);
setMessage(res.statusText)
return;
}
const data = await res.json();
console.log(data)
setMessage(JSON.stringify(data));
setLoading(false);
}
return (
<div className={'h-screen w-screen flex items-center justify-center'}>
<Card className={'w-full max-w-lg'}>
<CardHeader>
<CardTitle>
<div className={'flex w-full justify-between items-center'}>
<span>Message</span>
<Button disabled={loading} onClick={getMessage}>
{
loading ? <Loader className={'animate-spin h-5 w-5'} /> : <span>Get</span>
}
</Button>
</div>
</CardTitle>
<CardContent>
<p className={''}>
{message}
</p>
</CardContent>
</CardHeader>
</Card>
</div>
);
}
Conclusion: Embrace the Decoupled Architecture
Woohoo! You've made it! In this tutorial, we learned how to create a decoupled architecture with Next.js frontends and a Hono backend. This setup is more maintainable, scalable, and opens up a ton of possibilities for your web applications.
We used Better Auth, Prisma, and Hono to build a solid foundation. But remember, this is just the beginning! Here are some ideas for taking this further:
Add more authentication providers: Integrate social logins (Google, Facebook, etc.).
Implement role-based access control (RBAC): Control what different users can access.
Optimize performance: Use caching and other techniques to make your backend faster.
Testing: Implement end to end and unit tests to have robust code.
Take a look at the source code for this project at Github
Subscribe to my newsletter
Read articles from Aswin Lal directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Aswin Lal
Aswin Lal
Full Stack Developer and a Graphic Designer from God's Own Country. Trying to satisfy my curious heart