This post was originally published in my website here.
Authentication is a critical foundation for every contemporary web application, but establishing it is often one of the most significant, time-intensive hurdles developers encounter. The solution? Introducing Better Auth API: a framework-independent authentication service designed to revolutionize user management implementation. This detailed tutorial will guide you through creating a fully functional, full-stack application, showcasing the efficiency and ease of Better Auth, powered by Astro for optimal performance.
Why Better Auth?
Traditional auth solutions often require extensive boilerplate code and complex configuration. Better Auth offers:
- Zero boilerplate setup
- Type-safe APIs with full TypeScript support
- Multiple provider support (email/password, OAuth, passkeys)
- Framework-agnostic design that works with Astro, React, and beyond
Better Auth automatically handles Astro's unique server-client boundary, providing authentication that works across both environments. Better Auth provides a unified API that adapts to your stack—whether you're using React, Vue, Svelte, or vanilla JavaScript on the frontend, and Node.js, Bun, Deno or any framekworks on javascript ecosystem on the backend.
Setting up Astro
Astro's hybrid architecture (server and client components) works seamlessly with Better Auth. Better Auth requires a database to persist user and session data and leverages Server-Side Rendering (SSR) to manage sessions via cookies.
🛠️ Prerequisites
- A new Astro project (e.g., initialized with pnpm create astro@latest).
- A database (e.g., PostgreSQL or MongoDB) configured and ready.
- An SSR adapter for Astro (e.g., Cloudflare, Vercel, Netlify, or Node) added to your project.
pnpm create astro@latest
Create a Neon (Serverless Postgres) app
If you do not have one already, create a Neon (Serverless Postgres) project.
Save your connection details including your password. Here is the steps involved:
- Navigate to the Projects page in the (Neon Console)[https://neon.com/].
- Click New Project.
- Specify your project settings and click Create Project.
- Create a .env file in the root directory of your Astro project, and define a DATABASE_URL key with the connection string obtained as the value.
Adding dependencies
pnpm i better-auth pg @types/pg @astrojs/cloudflare @astrojs/react @tailwindcss/vite react tailwindcss wrangler
- better-auth — Authentication toolkit providing server/client auth APIs for modern web apps.
- pg — PostgreSQL client for Node.js used to connect and query Postgres databases.
- @types/pg — TypeScript type definitions for the pg (PostgreSQL) library.
- @astrojs/cloudflare — Astro adapter for deploying your Astro app to Cloudflare Workers.
- @astrojs/react — Enables using React components inside an Astro project.
- @tailwindcss/vite — Integrates Tailwind CSS directly into Astro/Vite for fast builds and hot reload.
- react — UI library for building interactive components inside Astro.
- tailwindcss — Utility-first CSS framework used for styling your Astro + React project.
- wrangler — Cloudflare’s CLI tool for building and deploying Workers and Worker-based apps.
Adding environment variables
BETTER_AUTH_SECRET="a-32-character-secret"
DATABASE_URL="your database connection string"
You can generate a secret with the code snippet below
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Astro gives you access to Vite’s built-in environment variables support and includes some default environment variables for your project that allow you to access configuration values for your current project.
Adding better-auth
Better Auth comes with first class support for Astro.
We will be creating two utility files, src/auth.ts and src/auth-client.ts to access Better Auth on the server side and client side, respectively.
In the src/auth-client.ts (code below), we are instantiating a new better-auth instance to be used in the client side interactions.
//src/auth-client.ts
import { createAuthClient } from "better-auth/react"
export const { useSession, authClient } = createAuthClient()
In src/auth.ts (code below), you are going to use a Pool to connect to your Postgres instance to persist the sessions and user object in the database, and enable email and password authentication. Astro uses Vite’s built-in support for environment variables, which are statically replaced at build time, and lets you use any of its methods to work with them.
We use import.meta.env.DATABASE_URL to access environment variables inside Astro.
//src/auth.ts
import pkg from 'pg'
import { betterAuth } from 'better-auth'
const { Pool } = pkg
export const auth = betterAuth({
emailAndPassword: { enabled: true },
database: new Pool({ connectionString: import.meta.env.DATABASE_URL })
})
Generating schema and API routes
To create the schema per our configuration defined in src/auth.ts file in your database automatically, execute the command below:
npx @better-auth/cli migrate
Let's define an API route to allow you to authenticate users.
Better Auth does the heavy lifting of creating and managing the logic of validating credentials, creating (or updating) the user and relevant session objects in the database. You just need to create a catch-all api route in your Astro project as follows in the src/pages/api/auth/[...all].ts file:
import { auth } from '@/auth'
import type { APIRoute } from 'astro'
export const ALL: APIRoute = async (ctx) => {
return auth.handler(ctx.request)
}
Intercept all incoming requests using Astro middleware
To make sure that each request maintains a user and session information is accessible over the server-side endpoints, and in .astro pages during server-side rendering, you are going create a middleware that uses Better Auth to decode/encode a user session from the cookie.
Create a file middleware.ts in the src directory with the following code:
import { auth } from '@/auth'
import { defineMiddleware } from 'astro:middleware'
export const onRequest = defineMiddleware(async (context, next) => {
const isAuthed = await auth.api.getSession({
headers: context.request.headers,
})
if (isAuthed) {
context.locals.user = isAuthed.user
context.locals.session = isAuthed.session
} else {
context.locals.user = null
context.locals.session = null
}
return next()
})
This will make sure to mark both user and session as null by default, and assign the respective values obtained from the database using the relevant cookies from the request. This allows you to always know the correct state of user authentication.
Creating Index Page
---
import "@/styles/global.css";
import DashboardPage from "@/components/Dashboard";
import Layout from "@/layouts/Layout.astro";
if (!Astro.locals.user?.id) return Astro.redirect("/signin");
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body class="container mx-auto p-4">
<Layout>
<DashboardPage client:load />
<Layout />
</Layout>
</body>
</html>
This Redirects user to the sign in page if the user is not authenticated.
Shows the information stored in the user object pertaining to the authenticated user.
Let's create a react component for our dashboard page.
import { useEffect } from "react";
import { authClient, useSession } from "@/auth-client";
import "@/styles/global.css";
export default function DashboardPage() {
const {
data: session,
isPending,
error,
refetch
} = useSession()
useEffect(() => {
// If not logged in → redirect to signin
if (!isPending && !session?.user?.id) {
window.location.href='/'
}
}, [session, isPending]);
const handleSignOut = async (): Promise<void> => {
await authClient.signOut();
window.location.href = '/signin'
};
if (isPending) {
return <p>Loading...</p>;
}
if (!session?.user) {
return null;
}
return (
<div className="container mx-auto py-4">
<h1 className="font-bold text-xl">
Hello {session.user.name ?? "User"}
</h1>
<h2 className="text-green-500 font-bold text-3xl">
You've authenticated successfully!
</h2>
<pre className="mt-4 bg-gray-100 p-4 rounded text-sm">
{JSON.stringify(session.user, null, 2)}
</pre>
<button
onClick={handleSignOut}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded"
>
Sign Out
</button>
</div>
);
}
Creating Signup
We created a signup page inside the src/pages and imported a React component and used a client:load directive so its hydrated in client. This helps to load and hydrate the component JavaScript immediately on page load.
---
import SignupForm from '@/components/SignupForm'
import Layout from '@/layouts/Layout.astro'
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body>
<Layout>
<SignupForm client:load/>
<Layout />
</body>
</html>
Also we will make a signup form component which will be a simple form to create a user inside of our Neon database.
import React, { useState } from "react";
import type { FormEvent } from "react";
import { authClient } from "@/auth-client";
export default function SignupPage(): React.JSX.Element {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setIsLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const name = (formData.get("name") as string) ?? "";
const email = (formData.get("email") as string) ?? "";
const password = (formData.get("password") as string) ?? "";
const response = await authClient.signUp.email({
name,
email,
password,
});
if (!response.error) {
window.location.href = '/'
} else {
setError(response.error.message ?? "Signup failed");
}
setIsLoading(false);
};
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4">
<h1 className="font-bold text-3xl py-8">
Sign up
</h1>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-3 w-full max-w-sm"
>
<input
required
type="text"
name="name"
placeholder="Name"
className="border p-2 rounded"
/>
<input
required
type="email"
name="email"
placeholder="Email"
className="border p-2 rounded"
/>
<input
required
type="password"
name="password"
placeholder="Password"
className="border p-2 rounded"
/>
<button
type="submit"
disabled={isLoading}
className="p-2 bg-blue-600 text-white rounded"
>
{isLoading ? "Signing up..." : "Sign up"}
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</form>
<p className="mt-4">
Already have an account?{" "}
<a href="/signin" className="underline">
Sign in
</a>
</p>
</div>
);
}
Creating Signin
Let's also create a signin page so that we can verify user exists in our DB.
In the frontmatter we check if user is available we redirect to main route.
---
import Layout from "@/layouts/Layout.astro"
import SigninForm from "@/components/SigninForm"
if (Astro.locals.user?.id) return Astro.redirect('/')
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body>
<Layout>
<SigninForm client:load/>
<Layout />
</body>
</html>
Same like before we will create a form submission so that we can login the user and redirect to our desired route.
import type { FormEvent } from "react";
import { useState } from "react";
import { authClient } from "@/auth-client";
export default function SigninPage() {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setIsLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const email = (formData.get("email") as string) ?? "";
const password = (formData.get("password") as string) ?? "";
const response = await authClient.signIn.email({
email,
password,
});
if (!response.error) {
window.location.href = '/';
} else {
setError(response.error.message ?? "Sign-in failed");
}
setIsLoading(false);
};
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4">
<h1 className="font-bold text-3xl py-8">
Sign in
</h1>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-3 w-full max-w-sm"
>
<input
required
type="email"
name="email"
placeholder="Email"
className="border p-2 rounded"
/>
<input
required
type="password"
name="password"
placeholder="Password"
className="border p-2 rounded"
/>
<button
type="submit"
disabled={isLoading}
className="p-2 bg-blue-600 text-white rounded"
>
{isLoading ? "Signing in..." : "Sign In"}
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</form>
<p className="mt-4">
Don't have an account?{" "}
<a href="/signup" className="underline">
Sign up
</a>
</p>
</div>
);
}
We enabled user authentication via credentials method with the help of Better Auth in an Astro application. You have also gained some experience with using middleware in Astro, and understanding how it can help you build dynamic user interfaces.
Conclusion
Whether we're building a content-focused Astro site or a dynamic React application, Better Auth provides the secure, modern authentication solution you need without the typical complexity.
Better Auth's greatest strength might be its developer experience. With automatic API route generation in Astro and React hooks that provide immediate user state, you can implement complete auth flows in minutes rather than days.
Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.
You can find the complete source code in Github Link here.
Thank you!
Top comments (0)