--- title: Building a secure React Backend with Neon Auth and Hono subtitle: Learn how to authenticate requests using Neon Auth JWTs in a custom backend for your React app. author: dhanush-reddy enableTableOfContents: true createdAt: '2025-12-30T00:00:00.000Z' updatedOn: '2025-12-30T00:00:00.000Z' --- This guide demonstrates how to integrate a **standalone React frontend** with a **custom backend API**, using [Neon Auth](/docs/auth/overview) to handle identity securely. Unlike frameworks that blend frontend and backend logic (like Next.js), this guide follows a decoupled architecture pattern. You will build a React Single Page Application (SPA) that communicates with a separate Hono server via a REST API. 1. **Identity (Neon Auth):** Handles sign-ups, logins, and issues **JSON Web Tokens (JWTs)**. 2. **Frontend (React):** Manages the user interface and attaches the JWT to API requests as a Bearer token. 3. **Backend (Hono):** A lightweight Node.js server that verifies the token signature using Neon's **JWKS endpoint** before allowing access to the database. This approach is ideal when you need a dedicated backend for complex business rules, third-party integrations (like Stripe or OpenAI), or microservices, while still offloading user management complexities to Neon. In this tutorial, you will build a **Private Journal** application where users can securely log in, create, and view their journal entries. The backend will validate JWTs from Neon Auth to ensure that only authenticated users can access their data. ## Prerequisites Before you begin, ensure you have the following: - **Node.js:** Version `18` or later installed on your machine. You can download it from [nodejs.org](https://nodejs.org/). - **Neon account:** A free Neon account. If you don't have one, sign up at [Neon](https://console.neon.tech/signup). ## Create a Neon project with Neon Auth You'll need to create a Neon project and enable Neon Auth. 1. **Create a Neon project:** Navigate to [pg.new](https://pg.new) to create a new Neon project. Give your project a name, such as `journal-app`. 2. **Enable Neon Auth:** - In your project's dashboard, go to the **Neon Auth** tab. - Click on the **Enable Neon Auth** button to set up authentication for your project. 3. **Copy your credentials:** - **Neon Auth URL:** Found on the **Auth** page (e.g., `https://ep-xxx.neon.tech/neondb/auth`). ![Neon Auth URL](/docs/auth/neon-auth-base-url.png) - **Database connection string:** Found on the **Dashboard** (select "Pooled connection"). ![Connection modal](/docs/connect/connection_details.png) ## Setup the Backend (Hono) You will create a Hono backend that verifies JWTs from Neon Auth and persists journal entries to Neon database. ### Initialize the backend In a terminal, run the following commands to create a new Hono project: ```bash npm create hono@latest journal-backend ``` > You can choose the runtime and package manager of your choice. For this guide, **Node.js** and `npm` are used. The output should look like this: ```plaintext $ npm create hono@latest journal-backend > npx > "create-hono" journal-backend create-hono version 0.19.4 ✔ Using target directory … journal-backend ✔ Which template do you want to use? nodejs ✔ Do you want to install project dependencies? Yes ✔ Which package manager do you want to use? npm ✔ Cloning the template ✔ Installing project dependencies 🎉 Copied project files ``` Navigate into the project directory: ```bash cd journal-backend ``` ### Install dependencies You will need `drizzle-orm` and `@neondatabase/serverless` for database access, `jose` for JWT verification, and `drizzle-kit` for migrations. ```bash npm install drizzle-orm @neondatabase/serverless dotenv jose npm install -D drizzle-kit ``` ### Configure environment variables Create a `.env` file in `journal-backend/` with the following content. Replace the placeholders with your actual Neon database connection string and Neon Auth URL that you copied in the [previous step](#create-a-neon-project-with-neon-auth). ```env # From Neon Dashboard DATABASE_URL="postgresql://alex:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/dbname?sslmode=require&channel_binding=require" # From Neon Auth Page NEON_AUTH_URL="https://ep-xxx.neon.tech/neondb/auth" ``` ### Set up Drizzle ORM Drizzle ORM will help you interact with your Neon Database. Create `drizzle.config.ts` in the root of your `journal-backend/` folder with the following content: ```typescript import 'dotenv/config'; import type { Config } from 'drizzle-kit'; export default { schema: './src/db/schema.ts', out: './drizzle', dialect: 'postgresql', schemaFilter: ['public', 'neon_auth'], dbCredentials: { url: process.env.DATABASE_URL!, }, } satisfies Config; ``` This config tells Drizzle Kit where to find your database schema and where to output migration files. The `schemaFilter` is configured to look at both the `public` and `neon_auth` schemas. The `neon_auth` schema is where Neon Auth stores its user data. ### Pull Neon Auth schema A key feature of Neon Auth is the automatic creation and maintenance of the Better Auth tables within the `neon_auth` schema. Since these tables reside in your Neon database, you can work with them directly using SQL queries or any Postgres‑compatible ORM, including defining foreign key relationships. To integrate Neon Auth tables into your Drizzle ORM setup, you need to introspect the existing `neon_auth` schema and generate the corresponding Drizzle schema definitions. This step is crucial because it makes Drizzle aware of the Neon Auth tables, allowing you to create relationships between your application data (like the `journal_entries` table) and the user data managed by Neon Auth. 1. **Introspect the database:** Run the Drizzle Kit `pull` command to generate a schema file based on your existing Neon database tables. ```bash npx drizzle-kit pull ``` This command connects to your Neon database, inspects its structure, and creates `schema.ts` and `relations.ts` files inside a new `drizzle` folder. This file will contain the Drizzle schema definition for the Neon Auth tables. 2. **Organize schema files:** Create a new directory `src/db`. Move the generated `schema.ts` and `relations.ts` files from the `drizzle` directory to `src/db/schema.ts` and `src/db/relations.ts` respectively. ``` ├ 📂 drizzle │ ├ 📂 meta │ ├ 📜 migration.sql │ ├ 📜 relations.ts ────────┐ │ └ 📜 schema.ts ───────────┤ ├ 📂 src │ │ ├ 📂 db │ │ │ ├ 📜 relations.ts <─────┤ │ │ └ 📜 schema.ts <────────┘ │ └ 📜 App.tsx └ … ``` 3. **Add the Journals table to the schema:** Open `src/db/schema.ts` to view the `neon_auth` tables that Drizzle generated from your existing Neon database schema. At the bottom of the file, append the `journals` table definition as shown below. You will also need to import the missing Drizzle types at the top of the file (e.g, `bigint`). ```typescript {9,39-46} shouldWrap import { pgTable, pgSchema, uuid, text, timestamp, unique, boolean, bigint, } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; export const neonAuth = pgSchema('neon_auth'); // .. other Neon Auth table definitions .. export const userInNeonAuth = neonAuth.table( 'user', { id: uuid().defaultRandom().primaryKey().notNull(), name: text().notNull(), email: text().notNull(), emailVerified: boolean().notNull(), image: text(), createdAt: timestamp({ withTimezone: true, mode: 'string' }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), updatedAt: timestamp({ withTimezone: true, mode: 'string' }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), role: text(), banned: boolean(), banReason: text(), banExpires: timestamp({ withTimezone: true, mode: 'string' }), }, (table) => [unique('user_email_key').on(table.email)] ); export const journalEntries = pgTable('journal_entries', { id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(), userId: uuid('user_id') .notNull() .references(() => userInNeonAuth.id), content: text('content').notNull(), createdAt: timestamp('created_at').defaultNow(), }); ``` The `journal_entries` table contains the following columns: `id`, `user_id`, `content` and `created_at`. It is linked to the `user` table in the `neon_auth` schema via a foreign key relationship on the `user_id` column. ### Generate and apply migrations Now, generate the SQL migration file to create the `journal_entries` table. ```bash npx drizzle-kit generate ``` This creates a new SQL file in the `drizzle` directory. Apply this migration to your Neon database by running: This is a [known issue](https://github.com/drizzle-team/drizzle-orm/issues/4851) in Drizzle. If `drizzle-kit pull` generated an initial migration file (e.g., `0000_...sql`) wrapped in block comments (`/* ... */`), `drizzle-kit migrate` may fail with an `unterminated /* comment` error. To resolve this, manually delete the contents of the `0000_...sql` file or replace the block comments with line comments (`--`). ```bash npx drizzle-kit migrate ``` Your `journal_entries` table now exists in your Neon database. You can verify this in the **Tables** section of your Neon project dashboard. ### Create the Hono server Update `src/index.ts` with the following code to set up the Hono server with JWT verification and routes for managing journal entries. ```typescript shouldWrap import { serve } from '@hono/node-server'; import { Hono, type Context, type Next } from 'hono'; import { cors } from 'hono/cors'; import { neon } from '@neondatabase/serverless'; import { drizzle } from 'drizzle-orm/neon-http'; import { eq, desc } from 'drizzle-orm'; import * as jose from 'jose'; import { journalEntries } from './db/schema.js'; import 'dotenv/config'; type AppVariables = { userId: string }; const app = new Hono<{ Variables: AppVariables }>(); const sql = neon(process.env.DATABASE_URL!); const db = drizzle(sql); const JWKS = jose.createRemoteJWKSet( new URL(`${process.env.NEON_AUTH_URL}/.well-known/jwks.json`) ); const authMiddleware = async (c: Context<{ Variables: AppVariables }>, next: Next) => { const authHeader = c.req.header('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } const token = authHeader.split(' ')[1]; try { const { payload } = await jose.jwtVerify(token, JWKS, { issuer: new URL(process.env.NEON_AUTH_URL!).origin, }); if (!payload.sub) { return c.json({ error: 'Invalid Token' }, 401); } c.set('userId', payload.sub); await next(); } catch (err) { console.error('Verification failed:', err); return c.json({ error: 'Invalid Token' }, 401); } }; app.use( '/*', cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173', allowMethods: ['GET', 'POST', 'PUT', 'DELETE'], allowHeaders: ['Content-Type', 'Authorization'], }) ); app.get('/api/entries', authMiddleware, async (c) => { const userId = c.get('userId'); const entries = await db .select() .from(journalEntries) .where(eq(journalEntries.userId, userId)) .orderBy(desc(journalEntries.createdAt)); return c.json(entries); }); app.post('/api/entries', authMiddleware, async (c) => { const userId = c.get('userId'); const { content } = await c.req.json(); const [newEntry] = await db.insert(journalEntries).values({ userId, content }).returning(); return c.json(newEntry); }); serve( { fetch: app.fetch, port: 3000, }, (info) => { console.log(`Backend server running at http://localhost:${info.port}`); } ); ``` The code above does the following: 1. **Server setup** - Initializes a **Hono server** running on port 3000. - Configures **CORS** to allow requests from `http://localhost:5173` with common HTTP methods and headers. In a production environment, adjust the CORS settings to match your frontend's domain. 2. **Database integration** - Connects to a **Neon Postgres database** using the `@neondatabase/serverless` client. - Utilizes **Drizzle ORM** for database operations. - Uses the `journalEntries` schema to store and retrieve user journal data. 3. **Authentication middleware** - Implements middleware that checks for a **Bearer token** in the `Authorization` header. - Verifies the token against **Neon Auth’s JWKS endpoint** using the `jose` library. - Extracts the authenticated user’s ID (`sub`) and attaches it to the request context. - Rejects requests with invalid or missing tokens, returning `401 Unauthorized`. 4. **API endpoints** - **GET `/api/entries`** Retrieves all journal entries belonging to the authenticated user, ordered by creation date (newest first). - **POST `/api/entries`** Accepts JSON input with `content`, creates a new journal entry tied to the authenticated user, and returns the newly created entry. This backend securely handles user authentication and data persistence, ensuring that only authenticated users can access and modify their journal entries. ### Start the backend ```bash npm run dev ``` Your backend server should now be running at `http://localhost:3000`. Now that the backend is set up, you can proceed to create the React frontend. ## Setup the Frontend (React) Now you will build the React frontend that handles user authentication and interacts with the backend API. ### Initialize React Open a new terminal window (leave the backend running) and create the frontend. ```bash npm create vite@latest journal-frontend -- --template react-ts ``` When prompted: - Select "No" for "Use rolldown-vite (Experimental)?" - Select "No" for "Install with npm and start now?" You should see output like this: ```plaintext $ npm create vite@latest journal-frontend -- --template react-ts > npx > "create-vite" journal-frontend --template react-ts │ ◇ Use rolldown-vite (Experimental)?: │ No │ ◇ Install with npm and start now? │ No │ ◇ Scaffolding project in /home/journal-frontend... │ └ Done. ``` ### Install dependencies Navigate into the project directory and install the required dependencies: ```bash cd journal-frontend && npm install npm install @neondatabase/neon-js react-router ``` ### Configure Tailwind CSS Install Tailwind CSS and the Vite plugin: ```bash npm install tailwindcss @tailwindcss/vite ``` Add the `@tailwindcss/vite` plugin to your Vite configuration (`vite.config.ts`): ```javascript import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; // [!code ++] export default defineConfig({ plugins: [ react(), tailwindcss(), // [!code ++] ], }); ``` ### Configure environment variables Create `.env` in `journal-frontend/`: ```env VITE_NEON_AUTH_URL="https://ep-xxx.neon.tech/neondb/auth" VITE_API_URL="http://localhost:3000/api" ``` ### Initialize Auth client Create `src/neon.ts` with the following content to initialize the Neon Auth client: ```typescript import { createAuthClient } from '@neondatabase/neon-js/auth'; export const authClient = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL); ``` ### Update application entry point Update `src/main.tsx` to wrap your app in the `NeonAuthUIProvider` and `BrowserRouter` to enable routing and provide authentication context throughout the app. ```tsx shouldWrap import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import { NeonAuthUIProvider } from '@neondatabase/neon-js/auth/react/ui'; import App from './App.tsx'; import { authClient } from './neon.ts'; import './index.css'; createRoot(document.getElementById('root')!).render( ); ``` ### Update global styles Replace the content of `src/index.css` with the following minimal Tailwind CSS setup: ```css @import 'tailwindcss'; @import '@neondatabase/neon-js/ui/tailwind'; :root { font-family: system-ui, sans-serif; line-height: 1.5; font-weight: 400; color: #0f172a; background-color: #f3f4f6; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { margin: 0; min-height: 100vh; background: #000000; } ``` This also includes Neon Auth's Tailwind styles required for the authentication components to render correctly. ### Create Auth and Account pages Neon provides pre‑built UI components for handling the complete flow of authentication, including Sign In, Sign Up, and Account management. As outlined in the [Neon Auth React UI guide](/docs/auth/quick-start/react-router-components), you can use the `AuthView` and `AccountView` components to quickly set up these pages. Create `src/pages/Auth.tsx`: ```tsx import { AuthView } from '@neondatabase/neon-js/auth/react/ui'; import { useParams } from 'react-router'; export default function AuthPage() { const { pathname } = useParams(); return (
); } ``` Create `src/pages/Account.tsx`: ```tsx import { AccountView } from '@neondatabase/neon-js/auth/react/ui'; import { useParams } from 'react-router'; export default function AccountPage() { const { pathname } = useParams(); return (
); } ``` ### Create API helper Create `src/api.ts`. This helper manages fetching the JWT and attaching it to requests. ```typescript import { authClient } from './neon'; const API_URL = import.meta.env.VITE_API_URL; export const api = { request: async (endpoint: string, options: RequestInit = {}) => { const { data } = await authClient.getSession(); const token = data?.session?.token; if (!token) { throw new Error('No active session'); } const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...options.headers, }; const response = await fetch(`${API_URL}${endpoint}`, { ...options, headers, }); if (!response.ok) throw new Error('API Request Failed'); return response.json(); }, getEntries: () => api.request('/entries'), createEntry: (content: string) => api.request('/entries', { method: 'POST', body: JSON.stringify({ content }), }), }; ``` The `api` object provides methods to interact with the backend API. It retrieves the current session's JWT using `authClient.getSession()`, attaches it as a Bearer token in the `Authorization` header, and performs fetch requests to the backend. ### Modify App component Modify `src/App.tsx` to implement the journal UI and routes. ```tsx shouldWrap import { useState, useEffect } from 'react'; import { RedirectToSignIn, SignedIn, UserButton } from '@neondatabase/neon-js/auth/react/ui'; import { api } from './api'; import { Route, Routes } from 'react-router'; import Auth from './pages/Auth'; import Account from './pages/Account'; type Entry = { id: number; content: string; createdAt: string }; function Journal() { const [entries, setEntries] = useState([]); const [newEntry, setNewEntry] = useState(''); useEffect(() => { api.getEntries().then(setEntries).catch(console.error); }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!newEntry) return; const entry = await api.createEntry(newEntry); setEntries([entry, ...entries]); setNewEntry(''); }; return (
Daily Journal

My Private Journal