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.

src/server/session.ts
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.

src/middleware/auth.ts
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:

src/api/auth/login.ts
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;
}
src/routes/login.tsx
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

src/api/auth/logout.ts
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:

src/routes/dashboard.tsx
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

src/routes.ts
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 403 or redirect.
  • Never store passwords or secrets in loader data โ€” it gets serialized to the client. Only return what the component needs.