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

Aswin LalAswin Lal
15 min read

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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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!

  1. 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/ folder

  • utils/ 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
            },
        },
    },
});
...
  1. 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

  1. from Server side

  2. 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.

  1. Create the Next.js App:

Open your terminal and run:

bun create next-app@latest
  1. Install Dependencies:
bun add better-auth
  1. 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:

  1. 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.

  1. 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

  1. 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;
  1. 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

0
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