When Limen launched, the TypeScript client was still on the roadmap. It's here now: limen-auth, a typed client for your Limen server with first-class adapters for React, Vue, Svelte, and Solid.
This post builds a full-stack auth flow: email/password sign-up and sign-in, a session-aware UI, and email verification.
- Backend: a Go server running Limen with the credential-password plugin and Postgres
-
Frontend: a Vite + React app using
limen-auth
You'll need Go 1.25+, Node 20+, and a Postgres database. If you have Docker, this gets you one:
docker run --name limen-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
Part 1: The Go server
Install
Create a new module and pull in Limen, the SQL adapter, and the credential-password plugin:
mkdir limen-app && cd limen-app
go mod init example.com/limen-app
go get github.com/thecodearcher/limen
go get github.com/thecodearcher/limen/adapters/sql
go get github.com/thecodearcher/limen/plugins/credential-password
go get github.com/lib/pq
Wire up the server
Create main.go:
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
_ "github.com/lib/pq"
"github.com/thecodearcher/limen"
sqladapter "github.com/thecodearcher/limen/adapters/sql"
credentialpassword "github.com/thecodearcher/limen/plugins/credential-password"
)
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
auth, err := limen.New(&limen.Config{
BaseURL: "http://localhost:8080",
Database: sqladapter.NewPostgreSQL(db),
CLI: &limen.CLIConfig{Enabled: true}, // lets the limen CLI generate migrations
HTTP: limen.NewDefaultHTTPConfig(
// Allow the React dev server, which runs on a different origin.
limen.WithHTTPTrustedOrigins([]string{"http://localhost:5173"}),
// Local dev runs over plain HTTP; re-enable in production.
limen.WithHTTPCookieSecure(false),
),
Email: limen.NewDefaultEmailConfig(
limen.WithEmailVerification(
limen.WithSendEmailVerificationMail(func(email, token string) {
// Plug in your email provider here. For now, log the link.
log.Printf("verify %s: http://localhost:5173/verify-email?token=%s", email, token)
}),
),
),
Plugins: []limen.Plugin{
credentialpassword.New(),
},
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/auth/", auth.Handler())
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", cors(mux)))
}
// cors lets the React dev server on :5173 call the API with credentials.
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Origin") == "http://localhost:5173" {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
That's the auth server. credentialpassword.New() adds the email/password routes and auth.Handler() serves them under /auth, with password hashing, sessions, and the rest handled for you.
Note: The
WithHTTP*options and thecorsmiddleware are there only for cross-origin local dev: they let the browser send the session cookie from:5173to:8080over plain HTTP. In production you'd tighten the cookie flag and hand CORS to your framework's middleware.
The email callback logs the verification link for local development. In production, send that link through your email provider.
Set the environment before running:
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
export LIMEN_SECRET=$(openssl rand -hex 16)
Migrations
Limen ships a CLI that generates SQL migrations from your configured schema. Install it, run the server once so it writes the schema snapshot (.limen/schemas.json, add it to .gitignore), then generate and apply:
go install github.com/thecodearcher/limen/cmd/limen@latest
go run . # once; Ctrl-C after it starts
limen generate migrations -d postgres -c "$DATABASE_URL" -o ./migrations
The CLI generates migration files; applying them is up to your usual tool. With golang-migrate:
migrate -path ./migrations -database "$DATABASE_URL" up
Start the server again with go run . and the backend is done. Verify it end to end with curl:
curl -i http://localhost:8080/auth/signup/credential \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "password": "Correct-horse-1"}'
You get back the new user, a Set-Cookie session, and a verification link in the server logs.
A protected route of your own
Your own endpoints can require a session with auth.GetSession(r), which reads and validates the session from any request. Add this inside main(), after the /auth/ mount, and restart the server:
mux.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {
session, err := auth.GetSession(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session.User)
})
GetSession works identically in middleware, in Gin handlers via c.Request, or anywhere else an *http.Request exists.
Part 2: The React app
Set up the client
Scaffold a Vite app next to the server and install the SDK:
npm create vite@latest limen-web -- --template react-ts
cd limen-web && npm install limen-auth
Create the client once and share it across the app. Create src/auth-client.ts:
import { createAuthClient } from 'limen-auth/react'
import { credentialPasswordPlugin } from 'limen-auth/plugins'
export const auth = createAuthClient({
baseURL: 'http://localhost:8080',
plugins: [credentialPasswordPlugin()],
})
baseURL is the server origin; the SDK's default basePath is /auth, which matches where we mounted the handler. Cookies are the default transport, and the client already sends credentials: 'include'.
Sign up
Create src/SignUpForm.tsx:
import { useState } from 'react'
import { LimenError } from 'limen-auth'
import { auth } from './auth-client'
export function SignUpForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
try {
await auth.signUp.credential({ email, password })
// Signed in. The session store updates itself.
} catch (err) {
setError(err instanceof LimenError ? err.message : 'Something went wrong')
}
}
return (
<form onSubmit={onSubmit}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Create account</button>
</form>
)
}
Sign-up establishes a session on the server (auto sign-in is Limen's default), and the SDK writes it into its reactive session store. Any component watching the session re-renders on its own, so the form doesn't need redirect logic.
Sign in
Same shape. Create src/SignInForm.tsx:
import { useState } from 'react'
import { LimenError } from 'limen-auth'
import { auth } from './auth-client'
export function SignInForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
try {
await auth.signIn.credential({ credential: email, password })
} catch (err) {
setError(err instanceof LimenError ? err.message : 'Something went wrong')
}
}
return (
<form onSubmit={onSubmit}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Sign in</button>
</form>
)
}
Let the session drive the UI
auth.useSession() gives React a live view of the current session. It loads the session on mount and updates when the user signs in or out. Replace src/App.tsx:
import { useState } from 'react'
import { auth } from './auth-client'
import { SignInForm } from './SignInForm'
import { SignUpForm } from './SignUpForm'
import { Dashboard } from './Dashboard'
import { VerifyEmail } from './VerifyEmail'
export default function App() {
const { data, isPending } = auth.useSession()
const [mode, setMode] = useState<'signin' | 'signup'>('signin')
// With a router, make this a route instead.
if (window.location.pathname === '/verify-email') return <VerifyEmail />
if (isPending) return <p>Loading…</p>
if (data) return <Dashboard user={data.user} />
return (
<main>
{mode === 'signin' ? <SignInForm /> : <SignUpForm />}
<button onClick={() => setMode(mode === 'signin' ? 'signup' : 'signin')}>
{mode === 'signin' ? 'Need an account? Sign up' : 'Have an account? Sign in'}
</button>
</main>
)
}
Submitting the sign-in form updates the store, so the dashboard renders with no navigation code. Signing out clears the store, and the forms come back.
The dashboard
The signed-in view shows the user, their verification status, and calls the protected /api/profile route we wrote in Go. Create src/Dashboard.tsx:
import { useState } from 'react'
import { auth } from './auth-client'
type User = { id: string; email: string; emailVerifiedAt: string | null }
export function Dashboard({ user }: { user: User }) {
const [resend, setResend] = useState<'idle' | 'sent' | 'failed'>('idle')
const [profile, setProfile] = useState<string | null>(null)
async function resendVerification() {
try {
await auth.requestEmailVerification()
setResend('sent')
} catch {
setResend('failed')
}
}
async function loadProfile() {
// Your own API routes: plain fetch, cookie included.
const res = await fetch('http://localhost:8080/api/profile', { credentials: 'include' })
setProfile(JSON.stringify(await res.json(), null, 2))
}
return (
<main>
<h1>Signed in as {user.email}</h1>
{user.emailVerifiedAt ? (
<p>Email verified ✓</p>
) : (
<p>
Email not verified.{' '}
{resend === 'sent' ? (
'Check your inbox.'
) : (
<button onClick={resendVerification}>Resend verification email</button>
)}
{resend === 'failed' && ' Sending failed, try again.'}
</p>
)}
<button onClick={loadProfile}>Load profile from the Go API</button>
{profile && <pre>{profile}</pre>}
<button onClick={() => auth.signout()}>Sign out</button>
</main>
)
}
auth.signout() ends the session on the server and clears the store, so the useSession() guard in App flips back to the forms automatically. For your own endpoints, a plain fetch with credentials: 'include' carries the session cookie, and auth.GetSession on the Go side validates it.
Email verification
The verification email (for now, the server log line) links to /verify-email?token=... on the frontend. That page hands the token back to the server. Create src/VerifyEmail.tsx:
import { useEffect, useRef, useState } from 'react'
import { auth } from './auth-client'
export function VerifyEmail() {
const [status, setStatus] = useState<'verifying' | 'verified' | 'error'>('verifying')
const ran = useRef(false)
useEffect(() => {
if (ran.current) return // guard against double-run in React strict mode
ran.current = true
const token = new URLSearchParams(window.location.search).get('token')
if (!token) {
setStatus('error')
return
}
auth
.verifyEmail({ token })
.then(() => setStatus('verified'))
.catch(() => setStatus('error'))
}, [])
if (status === 'verifying') return <p>Verifying…</p>
if (status === 'error') return <p>This verification link is invalid or has expired.</p>
return (
<p>
Email verified. <a href="/">Continue to the app</a>
</p>
)
}
On success the SDK refreshes the session, so emailVerifiedAt is populated when the user returns to the dashboard.
What you built
A complete email/password auth flow: users can sign up, sign in, verify their email, and reach protected routes on both ends, with production-grade password hashing, session handling, and endpoint protection baked in.
Where to go from here
The same setup extends to:
- Password reset
- Custom user fields
- Social login with OAuth
- Two-factor authentication
- Vue, Svelte, and Solid adapters
The full client reference lives in the Client SDK docs, and the source for both sides is on GitHub.
Originally published at limenauth.dev.
Top comments (0)