Authentication
Protect routes with session-based auth using middleware, loaders, and API routes. This recipe covers login/logout flows, session management, and route guards.
Architecture
Auth in pracht follows a simple pattern: middleware checks the session before any loader runs. If there's no valid session, redirect to login. Loaders can read the authenticated user. API routes handle login/logout mutations.
- Middleware โ gate access, redirect unauthenticated users
- Loaders โ read session data, pass user to components
- API routes โ handle login/logout mutations
- Cookies โ store session tokens (set via API route response headers)
1. Session Utilities
Create a small session module that reads/writes signed cookies. This example uses a simple HMAC approach โ swap in your preferred session library.
const SECRET = process.env.SESSION_SECRET!;
export interface Session {
userId: string;
email: string;
}
export async function getSession(request: Request): Promise<Session | null> {
const cookie = request.headers.get("cookie") ?? "";
const match = cookie.match(/session=([^;]+)/);
if (!match) return null;
try {
const [payload, signature] = match[1].split(".");
const expected = await sign(payload);
if (signature !== expected) return null;
return JSON.parse(atob(payload));
} catch {
return null;
}
}
export async function createSessionCookie(session: Session): Promise<string> {
const payload = btoa(JSON.stringify(session));
const signature = await sign(payload);
return `session=${payload}.${signature}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`;
}
export function clearSessionCookie(): string {
return "session=; Path=/; HttpOnly; Max-Age=0";
}
async function sign(data: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}2. Auth Middleware
This middleware redirects unauthenticated users to the login page. Apply it to any route group that requires auth.
import type { MiddlewareFn } from "@pracht/core";
import { getSession } from "../server/session";
export const middleware: MiddlewareFn = async ({ request }) => {
const session = await getSession(request);
if (!session) {
const loginUrl = `/login?redirect=${encodeURIComponent(new URL(request.url).pathname)}`;
return { redirect: loginUrl };
}
// Pass user info downstream via a header (loaders can read it)
request.headers.set("x-user-id", session.userId);
request.headers.set("x-user-email", session.email);
};3. Login Page
The login page renders the form, while an API route handles credential validation and sets the session cookie:
import { createSessionCookie } from "../../server/session";
export async function POST({ request }: ApiRouteArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
const redirectTo = String(form.get("redirect") ?? "/dashboard");
// Replace with your actual auth logic
const user = await verifyCredentials(email, password);
if (!user) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
const cookie = await createSessionCookie({
userId: user.id,
email: user.email,
});
return new Response(null, {
status: 302,
headers: {
location: redirectTo,
"set-cookie": cookie,
},
});
}
async function verifyCredentials(email: string, password: string) {
// Your DB lookup here
return null as any;
}import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
import { Form } from "@pracht/core";
export async function loader({ url }: LoaderArgs) {
return { redirect: url.searchParams.get("redirect") ?? "/dashboard" };
}
export function Component({ data }: RouteComponentProps<typeof loader>) {
return (
<div class="login-page">
<h1>Log in</h1>
<Form method="post" action="/api/auth/login">
<input type="hidden" name="redirect" value={data.redirect} />
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Password
<input type="password" name="password" required />
</label>
<button type="submit">Log in</button>
</Form>
</div>
);
}4. Logout
import { clearSessionCookie } from "../../server/session";
export async function POST(_args: ApiRouteArgs) {
return new Response(null, {
status: 302,
headers: {
location: "/",
"set-cookie": clearSessionCookie(),
},
});
}Trigger logout from anywhere with a form:
<Form method="post" action="/api/auth/logout">
<button type="submit">Log out</button>
</Form>5. Reading the User in Loaders
Behind the auth middleware, loaders can safely read user info from the headers set by middleware:
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
export async function loader({ request }: LoaderArgs) {
const userId = request.headers.get("x-user-id")!;
const projects = await db.projects.findMany({ userId });
return { userId, projects };
}
export function Component({ data }: RouteComponentProps<typeof loader>) {
return (
<div>
<h1>Dashboard</h1>
<ul>
{data.projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}6. Wire It Up
import { defineApp, group, route } from "@pracht/core";
export const app = defineApp({
shells: {
public: "./shells/public.tsx",
app: "./shells/app.tsx",
},
middleware: {
auth: "./middleware/auth.ts",
},
routes: [
// Public routes โ no auth
group({ shell: "public" }, [
route("/", "./routes/home.tsx", { render: "ssg" }),
route("/login", "./routes/login.tsx", { render: "ssr" }),
]),
// Protected routes โ auth middleware applied
group({ shell: "app", middleware: ["auth"] }, [
route("/dashboard", "./routes/dashboard.tsx", { render: "ssr" }),
route("/settings", "./routes/settings.tsx", { render: "ssr" }),
]),
],
});Tips
- Use
render: "ssr"for all auth-related routes โ they depend on cookies which are per-request. - For OAuth flows, handle the callback in an API route (
src/api/auth/callback.ts) that sets the session cookie and redirects. - For role-based access, extend the middleware to check permissions and return a
403or redirect. - Never store passwords or secrets in loader data โ it gets serialized to the client. Only return what the component needs.