Starter kit

An Astro starter, and what writing it taught us about our own SDK

There is an Astro starter now, at rekey-dev/astro-starter. Sessions, sign-in, plans, checkout, entitlements and credits, and the pages ship no JavaScript at all. Writing it turned up two things about our own SDK that are worth saying out loud.

The Astro starter's home page, with a heading about auth and billing wired up and a grid describing each integration file.
Same shape as the Next.js kit, none of the JavaScript.

There is no Astro package, so the kit is the adapter

We publish adapters for Node and Next. Astro users get the node SDK and write their own session layer, so the starter includes one, in full, at about ninety lines. Two cookies, a read that refreshes, a write. It is not hidden in a helper because the two lines that matter are easy to get wrong quietly.

src/lib/session.ts

export async function getSession(cookies, request) {
  const access = cookies.get(ACCESS_COOKIE)?.value;
  if (access) {
    try {
      return { user: await rekey.auth.getCurrentUser(access), accessToken: access };
    } catch (err) {
      // Only an invalid token falls through to a refresh. Anything else is a
      // real error and is rethrown, so an API blip does not present itself to
      // your users as a mass logout.
      if (!(err instanceof RekeyError) || err.code !== 'USER_TOKEN_INVALID') throw err;
    }
  }
  // ... refresh, or null ...
}

The other one is the Secure flag on the session cookie. It is decided per request, from x-forwarded-proto and the host, rather than from import.meta.env.PROD. That is a build-time answer to a request-time question, and it fails in the expensive direction: guessing wrong on a real host means the browser refuses the cookie, which is loud and takes one variable to fix, while guessing wrong the other way puts a session credential on the wire in cleartext and nothing anywhere looks broken.

The cookie names and lifetimes match @rekey.dev/nextjs deliberately, so an app that moves between the two frameworks does not sign everybody out.

This should be a package

Every Astro user is now going to write their own version of both rules. That is tracked as issue 14.

Secrets that are not baked into the build

The obvious way to read a secret in Astro is import.meta.env.REKEY_SECRET. Vite inlines that at build time, so the key ends up in dist/, and a container built once and run in two environments carries the wrong one with nothing in the source to show it.

The starter declares its variables in astro.config.mjs and imports them from astro:env/server, which resolves at runtime. A missing variable then fails at boot, naming the variable, instead of turning into a 401 an hour later.

Why there is no React in it

This was meant to be the neat part. <SignIn> from @rekey.dev/react is stateless, and given an actionUrl it renders a plain form. Rendering it in Astro with no client directive should have given a real form with no JavaScript.

What it actually gives you is correct markup with no styling at all, because the package injects its stylesheet from a client effect that never runs. Adding client:load fixes the look by shipping React to a page that otherwise needs none.

So the starter writes the form in Astro, in about forty lines you can restyle by editing them, and React left the project entirely. That is the right answer for a starter and the wrong answer for the SDK, which is filed as issue 18.

The Astro starter's sign-in page, a plain card with email and password fields and a create account link.
A form, posting to an endpoint. Zero islands on the page, verified against the built server.

What the form posts to

src/pages/api/sign-in.ts

export const POST: APIRoute = async ({ request, cookies, redirect, url }) => {
  const form = await request.formData();
  const outcome = await rekey.auth.signIn({
    email: String(form.get('email') ?? ''),
    password: String(form.get('password') ?? ''),
  });

  if (outcome.mfaRequired) return redirect('/sign-in?mfa=1');

  setSession(cookies, request, outcome);
  const next = url.searchParams.get('next') ?? '/dashboard';
  // Only ever a path on this site; an absolute URL would make this an open redirect.
  return redirect(next.startsWith('/') && !next.startsWith('//') ? next : '/dashboard');
};

Middleware puts the session on Astro.locals once per request, and pages guard themselves with two lines. Billing is the same API as everywhere else: read the plans, post a slug, redirect to the checkout URL.

An Astro starter for auth and billing, with no JavaScript shipped | Rekey