●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Production Auth with Rork × Better Auth: Web + Mobile Unified Authentication
A hands-on guide to unifying your Rork mobile app and web admin under a single Better Auth backend. Covers email links, social, passkeys, and organizations — including every cookie and deep-link gotcha I hit in production.
After running three Rork apps in production, the part that ate the most time wasn't UI or in-app purchases — it was authentication. Web on Clerk, mobile on Supabase Auth, Apple Sign In bolted on separately, organizations rolled by hand. By the time I needed to delete a user, I had to update four different systems. That codebase taught me a lesson.
For new projects I now standardize on Better Auth, a TypeScript-first auth library that landed in late 2024. It's framework-agnostic, plugin-driven, and self-hosted. Unlike Clerk, you run it on your own server. The trade-off is worth it: one backend handles browsers, iOS, and Android with the same session model.
This article walks through unifying a Rork-generated mobile app (React Native + Expo) and a Next.js admin panel under a single Better Auth server, with every gotcha I hit along the way. All the code below is verified against Better Auth 1.2.x at the time of writing.
Why Better Auth Now — Compared to Clerk, Supabase Auth, and Auth.js
Let me start with the comparison, since I've shipped production apps on each of these.
Clerk has gorgeous UI components and B2B features (orgs, invites, SSO) work out of the box. But MAU pricing compounds quickly — once you cross 50K MAU on a free app, the math stops working for indie dev revenue. Clerk is the option that hurts when things go well.
Supabase Auth is tightly coupled with Postgres and cheap. The downside is that GoTrue's JWT-based session is a little awkward to sync with mobile cookies, and OS-specific features like Apple Sign In and Passkeys you mostly wrap yourself.
Auth.js (formerly NextAuth) is web-centric. React Native support remains thin.
Better Auth was designed to take the best of these and re-implement them in TypeScript. Specifically:
Sessions are a hybrid of DB-stored cookies and bearer tokens. Web uses HttpOnly cookies; mobile uses bearer tokens — both reference the same session row
Auth methods are plugins. Email/password, magic link, social, passkeys, OTP, 2FA, organizations, SSO, Stripe — all composable
DB adapters cover Drizzle, Prisma, Mongo, Kysely, so Cloudflare D1 or Neon Postgres just works through Drizzle
Frontend clients exist for React, Vue, Svelte, Solid, and React Native
In short: an auth flow that works on the web works the same way on mobile, against the same DB. If you're tired of stitching things together like I was, this lands hard.
Three Reasons Better Auth Fits Rork Apps
Rork generates React Native + Expo apps and deploys them on Cloudflare. If your auth backend also runs on Cloudflare, latency stays predictable. Better Auth pairs especially well with this setup, for three reasons.
First, you can run Better Auth as-is on Cloudflare Workers + Hono. auth.handler accepts a standard Request and returns a Response. One Hono route — app.all('/api/auth/*', c => auth.handler(c.req.raw)) — and you're done.
Second, Drizzle ORM lets you share Cloudflare D1 or Neon Postgres. If your Rork app already uses Drizzle for its backend, just add the auth schema to your existing tables. On my current project I reused the DB I'd set up in Rork × Neon Postgres + Drizzle ORM Backend Guide and only added auth-schema.ts.
Third, Expo deep links and cookies both work. This matters more than it sounds. You can have Apple Sign In redirect to myapp://auth/callback on mobile, while the web version uses cookies for the same session — without writing two implementations.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been juggling separate auth stacks for web and mobile, you can now collapse them into one Better Auth server with shared sessions, shared DB, and shared business logic
✦You'll learn how to ship the parts everyone gets stuck on — Apple Sign In's Service ID confusion, deep links breaking on Safari, cookies refusing to save on iOS — with copy-pasteable code that actually works
✦Whether you're a solo dev or building B2B SaaS, you'll come away with production patterns for organizations, passkeys, and cross-device session sync that scale from your first user to your first thousand
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Project Setup — Monorepo and a Cloudflare Workers Backend
Time to build. The setup I'll walk through is a monorepo with three apps — web (Next.js), mobile (Rork-generated Expo), backend (Cloudflare Workers + Hono) — sharing an auth/ package.
In packages/auth/server.ts, define the Better Auth server once. The code below explicitly imports every dependency so you can paste it and run.
// packages/auth/server.ts// Purpose: the Better Auth server shared by web and mobile.// Runtime: Cloudflare Workers + D1 or Neon Postgresimport { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { passkey } from "better-auth/plugins/passkey";import { magicLink } from "better-auth/plugins/magic-link";import { organization } from "better-auth/plugins/organization";import { expo } from "@better-auth/expo";import { db } from "@my-app/db";export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg" }), baseURL: process.env.BETTER_AUTH_URL!, // e.g. https://api.myapp.com secret: process.env.BETTER_AUTH_SECRET!, // 32+ random characters trustedOrigins: [ "https://myapp.com", "https://admin.myapp.com", // Mobile app schemes — match app.json "myapp://", "exp://192.168.1.10:8081", // Expo Go in development ], emailAndPassword: { enabled: true, requireEmailVerification: true }, socialProviders: { apple: { clientId: process.env.APPLE_CLIENT_ID!, clientSecret: process.env.APPLE_CLIENT_SECRET!, }, google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, }, plugins: [ expo(), // ⚠️ Switches to bearer tokens for React Native passkey({ rpName: "MyApp", rpID: "myapp.com" }), magicLink({ sendMagicLink: async ({ email, url }) => { // Send the email via Resend or SES — see below await sendEmail(email, "Sign in to MyApp", url); }, }), organization({ allowUserToCreateOrganization: true }), ],});async function sendEmail(to: string, subject: string, url: string) { // Implementation pattern in [Rork × Resend Email Integration Guide](/en/articles/rork-dev/rork-resend-email-integration-guide) throw new Error("Implement sendEmail with Resend or SES");}
The single most-important line is expo(). Without it, mobile requests try to set cookies and silently fail (browsers can store cookies; Expo runtimes can't, so the plugin swaps in bearer tokens). I lost half a day before figuring this out.
Now the Hono routes:
// workers/api/src/index.tsimport { Hono } from "hono";import { cors } from "hono/cors";import { auth } from "@my-app/auth/server";const app = new Hono();app.use( "/api/auth/*", cors({ origin: (origin) => origin, // Reflect any origin matching trustedOrigins credentials: true, allowMethods: ["GET", "POST", "OPTIONS"], }));app.all("/api/auth/*", (c) => auth.handler(c.req.raw));export default app;
Two CORS gotchas: forget credentials: true and cookies won't travel between web and API. Use origin: "*" and the browser will reject the response because * plus credentials: true is invalid. Reflecting the origin function-style is the only thing that works in production.
Magic Links That Open Your App, Not Safari
Magic links are my favorite flow for indie apps — passwordless, low friction, no password reset support burden. The catch on mobile is that tapping a link in Mail can open Safari instead of your app. Universal Links (iOS) and App Links (Android) fix that.
The mobile-side handler with Expo Router lives at /app/auth/callback.tsx:
// apps/mobile/app/auth/callback.tsx// Purpose: complete the sign-in flow when a magic-link email// or Apple Sign In redirects back into the app.import { useEffect } from "react";import { router, useLocalSearchParams } from "expo-router";import { ActivityIndicator, View, Text } from "react-native";import { authClient } from "@/lib/auth-client";export default function AuthCallbackScreen() { const params = useLocalSearchParams<{ token?: string; error?: string }>(); useEffect(() => { (async () => { if (params.error) { router.replace({ pathname: "/sign-in", params: { error: params.error } }); return; } if (!params.token) { router.replace("/sign-in"); return; } // Verify with Better Auth — the expo() plugin will store // the bearer token in SecureStore for us. const result = await authClient.magicLink.verify({ query: { token: params.token }, }); if (result.error) { router.replace({ pathname: "/sign-in", params: { error: result.error.message } }); return; } router.replace("/(tabs)/home"); })(); }, [params.token, params.error]); return ( <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}> <ActivityIndicator /> <Text style={{ marginTop: 12 }}>Completing sign-in…</Text> </View> );}
The Better Auth client is configured like this:
// apps/mobile/lib/auth-client.tsimport { createAuthClient } from "better-auth/react";import { expoClient } from "@better-auth/expo/client";import * as SecureStore from "expo-secure-store";export const authClient = createAuthClient({ baseURL: process.env.EXPO_PUBLIC_BETTER_AUTH_URL!, // https://api.myapp.com plugins: [ expoClient({ scheme: "myapp", storagePrefix: "myapp", storage: SecureStore, // ⚠️ Use SecureStore, not AsyncStorage }), ],});
Bearer tokens go into Keychain (iOS) / Keystore (Android) via SecureStore. I've seen tutorials that use AsyncStorage. Don't — anyone with physical access or a jailbroken device can read AsyncStorage in plain text.
What you should see end-to-end: user taps the email link, the OS opens the app directly (assuming Universal Links are wired up), /auth/callback?token=xxx runs magicLink.verify(), a session row appears in Postgres, and the bearer token lands in SecureStore. If they tap the link in a browser, the web build picks up the same flow with cookies instead.
Apple and Google Sign In on Mobile — The Real Gotchas
Apple Sign In is required for App Store apps that offer any other social provider (Guideline 4.8). Better Auth handles the OAuth dance, but on mobile you'll combine it with Expo's AuthSession or expo-apple-authentication.
// apps/mobile/components/AppleSignInButton.tsximport * as AppleAuthentication from "expo-apple-authentication";import { authClient } from "@/lib/auth-client";import { router } from "expo-router";export function AppleSignInButton() { const handlePress = async () => { try { const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL, ], }); // Send the identityToken to Better Auth for verification const result = await authClient.signIn.social({ provider: "apple", idToken: { token: credential.identityToken!, nonce: credential.nonce }, }); if (result.error) throw new Error(result.error.message); router.replace("/(tabs)/home"); } catch (e: any) { if (e.code === "ERR_REQUEST_CANCELED") return; // User cancelled console.error(e); } }; return ( <AppleAuthentication.AppleAuthenticationButton buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN} buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK} cornerRadius={12} style={{ width: "100%", height: 48 }} onPress={handlePress} /> );}
Three things will bite you. First, Apple only returns an email on the very first sign-in. If you don't persist it server-side that first time, returning users come back as anonymous accounts. Better Auth saves it by default, so be careful if you've added a custom onCreateUser hook that overrides this.
Second, the identityToken audience is your App ID, and that ID is case-sensitive. com.example.myapp and com.example.MyApp are not the same thing. Apple's docs don't spell this out; I lost two hours to it.
Third, simulators can't reliably test Apple Sign In. Build for real devices and provide Google Sign In as a fallback in dev.
Passkeys on iOS 17+ and Android 14+
Passkeys are the single biggest UX upgrade you can ship right now. Face ID / Touch ID / fingerprint, no password to remember. Better Auth's passkey plugin hides nearly all of WebAuthn's complexity.
// apps/mobile/components/PasskeyEnrollButton.tsximport { Pressable, Text, Alert } from "react-native";import { authClient } from "@/lib/auth-client";export function PasskeyEnrollButton() { const handlePress = async () => { try { const { data, error } = await authClient.passkey.addPasskey(); if (error) throw new Error(error.message); Alert.alert("Done", "Passkey saved. Next time, sign in with Face ID."); } catch (e: any) { Alert.alert("Error", e.message); } }; return ( <Pressable onPress={handlePress} style={{ padding: 16, backgroundColor: "#000", borderRadius: 12 }}> <Text style={{ color: "#fff", textAlign: "center" }}>Enroll a passkey on this device</Text> </Pressable> );}
The single requirement people miss: rpID must match the Universal Link domain. If rpID: "myapp.com", your apple-app-site-association needs a webcredentials block listing the same app. Without it, passkey enrollment fails silently with "not supported."
What you should see: tap the button, the OS shows its native passkey UI (Face ID prompt → saved to iCloud Keychain). On the next sign-in, authClient.signIn.passkey() completes with just Face ID, no email entry.
The organization Plugin for B2B SaaS
Even as a solo dev, you'll bump into B2B-flavored use cases sooner than you think. I recently shipped a "family plan" for a photo-management app — multiple accounts sharing one library. Better Auth's organization plugin gives you four things out of the box: organizations, members, invitations, and roles.
// In apps/mobile/screens/CreateOrganization.tsxconst result = await authClient.organization.create({ name: "Hirokawa Family", slug: "hirokawa-family",});// result.data.organization is the new orgawait authClient.organization.inviteMember({ organizationId: result.data.organization.id, email: "wife@example.com", role: "admin",});// Switch active org (call this on app launch as well)await authClient.organization.setActive({ organizationId: orgId });
The killer feature: activeOrganizationId rides on the session. When your backend calls auth.api.getSession(), you get session.activeOrganizationId for free. A single Hono middleware can enforce tenant isolation on every protected route.
This is where I most felt the switch was worth it. useSession() on web, useSession() on mobile — both read the same session table, so the server logic only exists once.
// apps/web/app/profile/page.tsx (Next.js Server Component)import { auth } from "@my-app/auth/server";import { headers } from "next/headers";export default async function ProfilePage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/sign-in"); return <main>{session.user.name}</main>;}
Cookie on web, bearer on mobile, but session.user.id and session.session.activeOrganizationId come back identical. Your API code trusts that ID and moves on.
Errors I Actually Hit (and How to Fix Them)
In rough order of how often I bumped into each one in production:
Error 1: "Cookie has been blocked" on mobile. You're not using bearer tokens. Confirm expo() on the server and expoClient() on the client. Without them, Better Auth tries to set a cookie that mobile can't store.
Error 2: Apple Sign In returns invalid_client. Your APPLE_CLIENT_ID is the Service ID, not the Bundle ID. Apple's docs are vague on this and most people get it wrong the first time. Look in Apple Developer → Certificates, Identifiers & Profiles → Identifiers → Services IDs (e.g. com.example.myapp.signin).
Error 3: Magic link opens Safari instead of the app. Universal Links aren't working. Check that /.well-known/apple-app-site-association is served as application/json, not text/html. Cloudflare Workers do this correctly when you use c.json().
Error 4: "crypto.subtle is undefined" on Cloudflare Workers. Add compatibility_flags = ["nodejs_compat"] and bump compatibility_date to 2024-09-23 or later in wrangler.toml. Older configs miss the Web Crypto APIs Better Auth uses.
Error 5: Passkey enrollment fails with NotAllowedError. Either your rpID doesn't match your domain, or your .well-known/apple-app-site-association is missing the webcredentials section. Open Safari Web Inspector → console for the detailed WebAuthn error.
Error 6: Mobile sign-in rejected with a CSRF error. You forgot to add myapp:// to trustedOrigins. Easy to miss when you only test against your production domain.
Production Checklist — Rate Limits, CSRF, Audit Logs
A few items I always confirm before shipping:
Rate limiting. Better Auth has built-in in-memory rate limiting, but it's per-instance, which means nothing in a distributed Workers deploy. Use KV or Durable Objects:
CSRF. Better Auth's CSRF protection is the trustedOrigins check. Don't set it to * to silence errors in dev — that disables the protection. Whitelist your real domains.
Audit logs. Sign-ins, org creations, member adds — log them to a separate table. Months later when someone asks "why does this account have admin access?", you'll be glad you can answer.
Testing the Whole Auth Flow Without Breaking in Production
Auth is the kind of code you don't get to test casually. The sign-in flow happens once per user, so a regression hides until someone complains. I run three layers of tests against my Better Auth setup, and I recommend them all.
The first layer is unit tests against the Better Auth server. Because the handler is just a fetch handler, you can call it directly without spinning up a real HTTP server.
// packages/auth/server.test.tsimport { describe, it, expect, beforeEach } from "vitest";import { auth } from "./server";import { db } from "@my-app/db";describe("Better Auth server", () => { beforeEach(async () => { await db.execute(sql`TRUNCATE users, sessions, accounts CASCADE`); }); it("creates a session on sign-up with email/password", async () => { const req = new Request("http://localhost/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "test@example.com", password: "test-password-123", name: "Test User", }), }); const res = await auth.handler(req); expect(res.status).toBe(200); const data = await res.json(); expect(data.user.email).toBe("test@example.com"); }); it("rejects sign-in with wrong password", async () => { // Create a user first ... const req = new Request("http://localhost/api/auth/sign-in/email", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "test@example.com", password: "wrong", }), }); const res = await auth.handler(req); expect(res.status).toBe(401); });});
The second layer is integration tests that exercise the mobile client against a local instance of the Workers backend. wrangler dev spins up a local server in seconds, and Better Auth's client works against it identically.
// apps/mobile/__tests__/auth-flow.test.tsimport { authClient } from "../lib/auth-client";beforeAll(async () => { // Start wrangler dev as a child process or assume it's running on :8787 process.env.EXPO_PUBLIC_BETTER_AUTH_URL = "http://localhost:8787";});test("magic link end to end", async () => { // 1. Trigger magic link const send = await authClient.signIn.magicLink({ email: "test@example.com" }); expect(send.error).toBeNull(); // 2. In tests, we can read the most recent token directly from the DB const token = await db.execute(sql`SELECT id FROM verification ORDER BY createdAt DESC LIMIT 1`); // 3. Verify it const verify = await authClient.magicLink.verify({ query: { token: token.rows[0].id } }); expect(verify.error).toBeNull(); expect(verify.data?.user.email).toBe("test@example.com");});
The third layer is manual smoke testing on real devices before a release. Apple Sign In, passkeys, and Universal Links all behave differently in simulators versus physical hardware. I keep a numbered checklist that I run on an iPhone and a Pixel before every store release. It takes ten minutes and has caught at least three release-blocking bugs that unit tests didn't.
Migrating From Clerk or Supabase Auth Without User Disruption
If you're not greenfield, the migration question matters more than the new architecture. The pattern that worked for me involves running both systems in parallel for a week.
Step one: stand up Better Auth alongside your existing auth. Don't replace anything yet. New users start signing up on Better Auth; existing users keep using the old system. Add a flag to your users table — migrated_at — defaulting to null.
Step two: add a "verify your email" prompt on the next sign-in for legacy users. When they click through, fire the Better Auth magic link, complete the verification, and set migrated_at = now(). This way users move when they next show up; you don't force a flag day.
Step three: once 95% of active users have migrated (typically two to four weeks for a healthy app), email the remaining 5% with a manual reset and remove the legacy code path.
The key insight is that Better Auth lets you import Clerk or Supabase user IDs into its users.id column directly. As long as your users.id stays stable, downstream foreign keys (orders, posts, photos) don't need to be touched. I built a one-off migration script that read every Clerk user via their API and inserted them into Better Auth's tables, then triggered the email-verify flow on next sign-in.
// scripts/migrate-from-clerk.tsimport { Clerk } from "@clerk/backend";import { db } from "@my-app/db";import { user } from "@my-app/auth/schema";const clerk = Clerk({ secretKey: process.env.CLERK_SECRET_KEY! });const allUsers = await clerk.users.getUserList({ limit: 500 });for (const u of allUsers.data) { await db.insert(user).values({ id: u.id, // Reuse the Clerk ID — keeps FKs stable email: u.emailAddresses[0].emailAddress, name: `${u.firstName ?? ""} ${u.lastName ?? ""}`.trim(), emailVerified: false, // They'll verify on next sign-in image: u.imageUrl, createdAt: new Date(u.createdAt), updatedAt: new Date(u.updatedAt), }).onConflictDoNothing();}
This kept downtime at zero on my last migration.
A Quick Word on Cloud-Hosted Alternatives
People sometimes ask whether self-hosting an auth server is worth the operational overhead. My honest answer: for a Cloudflare Workers backend, it costs almost nothing to run. Better Auth itself adds no recurring fee, and the DB calls are the same DB you'd be hitting anyway. The thing you're "paying" is the time to wire it up the first time, which this guide is here to compress.
If you'd rather pay money than spend time, Clerk and WorkOS are perfectly sound choices. The break-even point I've come to believe in: under 1,000 monthly active users where your time is worth more than $80/month, use a hosted service. Above that, or if you have any need to control the auth schema or customize flows beyond what the SaaS exposes, self-host with Better Auth.
There's also the in-between option of using Better Auth's genericOAuth plugin to delegate the actual identity verification to an OIDC provider while keeping the session model in your own DB. This is what I'd recommend if your concern is "I don't want to be a target for password-stuffing attacks" — let Auth0 or Cognito handle the credential storage, and use Better Auth purely as your session and orgs layer.
A Note on Performance
One thing that surprised me was how fast Better Auth runs on Cloudflare Workers. Sign-in latency in my staging environment is consistently 30-60ms p50 globally, because the Workers runtime is colocated near every user. Compared to a hosted auth service that has to round-trip to a single region, this is noticeably snappier on mobile networks.
The only operation where DB latency dominates is the initial user creation, since that does a transactional insert across the user and session tables. With Neon Postgres in the same region as your primary Workers PoP, you can keep this under 200ms. With D1 (which is single-writer), you'll see slightly higher tail latencies but still under a second in practice.
If you're measuring carefully, the customStorage rate-limit config above also affects perceived latency — KV reads are around 5-10ms p50. Don't put rate-limit storage on a slow backing store; it's hit on every request.
Where to Go From Here
Thanks for reading this far. Running web and mobile on separate auth stacks isn't just code duplication — it's a slow security tax, since you have to apply every fix in two places. Better Auth lets you pay that debt off in one go.
The most practical first step is to build one new project on Better Auth before migrating an existing one. Migrating a working Clerk install is painful; building fresh swaps that pain for a manageable learning curve, and from your second project onward authentication takes hours instead of weeks. My second app's auth setup took me an afternoon.
If you get stuck, the Better Auth Discord is unusually responsive — Bereket, the maintainer, often answers personally. Talking directly to the author of the auth library you depend on is rare in OSS, so make use of it.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.