Loomup Docs
Guide /docs/nextjs

Next.js SDK

Package: @loomup/next (SDK repository path: packages/next).

Builds on @loomup/client for App Router and Pages Router apps.

Session model

Loomup Next uses Next-owned HttpOnly cookies on your app origin:

CookiePurpose
loomup-accessJWT access token (same name as @loomup/astro)
loomup-refreshRefresh token

Requests to Loomup use the Authorization: Bearer header. Middleware and server clients read cookies, then attach the access token.

This is not the same as Loomup server auth.cookie_mode (which sets loomup_access / loomup_refresh with underscores on the Loomup origin). Prefer the Next cookie approach for cross-origin setups (e.g. Next on :3001, Loomup on :3000). Do not enable both modes on the same browser origin without a clear plan.

Install

bash
cd packages/client && npm install && npm run build
cd ../next && npm install && npm run build

# in your Next app
npm install @loomup/client @loomup/next
# or file: paths during monorepo development

Environment

bash
LOOMUP_URL=http://127.0.0.1:3000
NEXT_PUBLIC_LOOMUP_URL=http://127.0.0.1:3000

App Router setup

Auth routes

Use createAuthRouteHandlers so login/register set HttpOnly cookies:

ts
// app/api/auth/login/route.ts
import { createAuthRouteHandlers } from "@loomup/next";

const handlers = createAuthRouteHandlers({
  url: process.env.LOOMUP_URL!,
});

export async function POST(request: Request) {
  return handlers.login(request);
}

Provide the same for register, logout, refresh, and GET session. Full example: examples/next-todo.

The session and login responses may include access_token in JSON so Client Components can hydrate an in-memory Bearer token for REST + realtime (refresh remains HttpOnly).

Middleware

ts
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@loomup/next";

export async function middleware(request: NextRequest) {
  return updateSession(request, {
    url: process.env.LOOMUP_URL!,
    createResponse: () => NextResponse.next({ request }),
  });
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

updateSession decodes the access JWT exp when possible and calls Loomup POST /auth/refresh if the token is missing or near expiry (default skew 60s). Rotated tokens are written onto the middleware response cookies.

Server Components

ts
import { cookies } from "next/headers";
import { createServerClient } from "@loomup/next";

export default async function Page() {
  const client = await createServerClient({
    url: process.env.LOOMUP_URL!,
    cookies,
  });
  // REST only — do not call subscribe() on the server
  const { data } = await client.from("todos").select({ limit: 20 });
  return /* ... */;
}

Token rotation during a request (401 → refresh) invokes onTokens, which calls cookies().set. That works in Route Handlers and Server Actions; pure Server Components may not be able to mutate cookies mid-render — rely on middleware for proactive refresh.

Object storage (server)

Server clients already expose client.storage (same as @loomup/client). Helpers for Route Handlers:

ts
// app/api/upload/route.ts
import { cookies } from "next/headers";
import {
  createServerClient,
  uploadFromFormData,
  storageDownloadResponse,
} from "@loomup/next";

export async function POST(request: Request) {
  const client = await createServerClient({
    url: process.env.LOOMUP_URL!,
    cookies,
  });
  const me = await client.auth.me();
  const meta = await uploadFromFormData(
    client,
    "avatars",
    await request.formData(),
    { pathPrefix: `${me.id}/`, upsert: true },
  );
  return Response.json({ data: meta });
}

export async function GET(request: Request) {
  const client = await createServerClient({
    url: process.env.LOOMUP_URL!,
    cookies,
  });
  const path = new URL(request.url).searchParams.get("path")!;
  return storageDownloadResponse(client, "avatars", path);
}

Or call the client directly:

ts
await client.storage.from("avatars").upload("u/a.png", file, {
  contentType: file.type,
  upsert: true,
});

Requires server [storage].enabled = true and bucket rules in loomup.toml. See storage.md.

Client Components + realtime

ts
"use client";
import { createBrowserClient } from "@loomup/next";

const client = createBrowserClient({
  url: process.env.NEXT_PUBLIC_LOOMUP_URL!,
  accessToken, // from server props or /api/auth/session
});

const unsub = await client.from("todos").subscribeReady((ev) => {
  console.log(ev.op, ev.data);
});

Optional: wrap the tree in LoomupProvider / useLoomup from @loomup/next.

Pages Router

ts
import type { GetServerSideProps } from "next";
import { createPagesServerClient } from "@loomup/next";

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  const client = createPagesServerClient({
    url: process.env.LOOMUP_URL!,
    context: ctx,
  });
  if (!client.accessToken) {
    return { redirect: { destination: "/login", permanent: false } };
  }
  const { data } = await client.from("todos").select({ limit: 20 });
  return { props: { todos: data } };
};

Auth can use pages/api/* handlers that call the same createAuthRouteHandlers methods (login, logout, …) with the Fetch Request API, or manually set cookies via createPagesServerClient + auth.signIn.

Core client hook

@loomup/client exposes onTokens and setSession so adapters can persist sessions:

ts
createClient({
  url,
  onTokens: (tokens) => {
    // tokens === null on signOut
  },
});

Security

  • Keep refresh tokens HttpOnly (default cookie helpers do this).
  • Use Secure in production (NODE_ENV=production sets Secure by default).
  • SameSite=Lax is the default.
  • Exclude static assets from the middleware matcher.
  • Prefer auth through your Next origin (route handlers) rather than storing refresh tokens in localStorage.

Example

See examples/next-todo.