An Astro starter, and what writing it taught us about our own SDK
Starter kitThere 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.

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
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 and names the variable when it is missing.
Two corrections to that, both found by running the built server rather than reasoning about it. The built server does not read .env at all, because Vite loads that for astro dev only. And a missing variable does not fail at boot: the server starts and listens perfectly happily, then returns 500 on the first request. A health check that only asks whether the port is open will call that deploy healthy.
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.

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.
The bug that only appears once you deploy
The first version of this kit passed every local test and was completely non-functional in production. Astro checks the Origin header on form posts, and it builds the URL it compares against from the socket. Behind a TLS-terminating proxy that is http://your-host while the browser sends Origin: https://your-host. They do not match, and every sign-in, sign-up, checkout and cancel returns a bare 403.
Astro trusts X-Forwarded-Proto only once you tell it which host is yours, via security.allowedDomains. The kit sets that from PUBLIC_APP_URL now, and the README says it has to be present at build time, because that config file runs at build.
Found by asking, not by assuming
The other starters: Next.js with auth and billing, Astroand the digital shop.
