Host Studio for your team

Give a content team a Studio URL. Their changes still end up as commits.

Deploy & operate10 min read

needs Postgres

Locally, Studio saves to files and you commit. A hosted Studio has no files it can keep: a serverless function's filesystem is read-only, and a container's is thrown away on the next deploy. So a hosted Studio writes to GitHub instead. Every editor's saves go to a draft branch of their own, and publishing puts one commit on your production branch, or opens a pull request. Your host redeploys from that commit the way it would for a developer's push.

Nothing is stored outside Git. An editor's draft is a branch you can open on GitHub, and an agent with repository access can read or finish it with plain Git.

How it works

Editor doesWhat happens in the repository
SavesA commit on graft-studio/drafts/<editor>, authored by the editor
PublishesOne commit on the production branch with the selected entries, authored by the editor
Submits for reviewA branch and a pull request; nothing changes on production until it is merged
DiscardsThe draft branch goes back to the published version of those entries

Two things are guarded for you:

  • A save from a stale page is refused. If the entry changed since the editor opened it (another tab, another editor, an agent over MCP), Studio says so and lets them load the newer version or keep theirs.
  • Publishing never silently overwrites someone else. If an entry was published by someone else after the editor started their draft, Studio asks them to keep their version or take the published one.

1. Give Studio access to the repository

Pick one.

A GitHub App (recommended for a team). Create an app under your organisation's Developer settings with these repository permissions: Contents read and write, Pull requests read and write. Install it on the repository. Then set:

VariableValue
GRAFT_GITHUB_APP_IDThe app's ID
GRAFT_GITHUB_APP_PRIVATE_KEYThe private key you generated, as PEM. Literal \n sequences are accepted
GRAFT_GITHUB_APP_INSTALLATION_IDOptional. Found automatically from the repository

Studio exchanges the app's key for tokens that last an hour, so nothing is tied to one person's account.

A fine-grained personal access token (simplest). Give it access to the one repository, with Contents and Pull requests set to read and write, and set GRAFT_GITHUB_TOKEN.

Then, either way:

VariableValue
GRAFT_GITHUB_REPOowner/name
GRAFT_GITHUB_BRANCHThe branch your site deploys from. Default main
GRAFT_GITHUB_CONTENT_PATHWhere your content directory is in the repository. Default: your content directory relative to the project, usually content
GRAFT_STUDIO_PUBLISHcommit (publishing commits to the branch) or pull-request. Default commit

If the production branch is protected against direct pushes, use pull-request.

Even with commit, some publishes open a pull request: deletions and renames, and changes over 200 lines, so the riskiest changes get a second pair of eyes. Studio says why. Change the rule with content.review in graft.config.ts, including collections that are always reviewed, or turn it off with review: false. See Configuration.

2. Let people sign in

Set GRAFT_STUDIO_SECRET to at least 32 random characters (openssl rand -base64 48). Sign-in is off until it is set. Studio signs GitHub sessions, single sign-on sessions for people without an account, and command-line invite links with it, so changing it signs out everyone who came in that way and voids those links. Account sessions live in your database and are not affected.

Studio accounts. People sign in with an email and a password, kept in your database. The first time anyone opens Studio, it asks for the owner account. On a server anyone can reach, it also asks for a setup code, so only whoever deployed the site can claim it. graft serve prints the code when it starts, and graft studio setup-code prints it from the site's settings.

The owner invites everyone else from Settings, People: an email and a role make a link to pass on. Studio sends no email, and shows each link once. The same list changes roles, makes a link to set a new password for someone who forgot theirs, and removes access, which signs that person out at once. Nobody can remove their own access, and the last admin can't be made anything else. Five wrong passwords in a row lock an account for 15 minutes.

Sign in with GitHub. Create an OAuth app (Developer settings, OAuth Apps) with the callback URL https://<your-site>/api/studio/v1/auth/github/callback, and set:

VariableValue
GRAFT_GITHUB_CLIENT_IDThe OAuth app's client ID
GRAFT_GITHUB_CLIENT_SECRETIts client secret
GRAFT_STUDIO_URLThe public origin, such as https://shop.example.com

Someone you invited in Settings, People signs in with GitHub as their Studio account, with its role, when the invited email is one of the verified addresses on their GitHub account. Their first sign-in uses the invitation.

For anyone else, without a list, write access to the repository decides. Studio asks GitHub with its own credential, which needs Metadata read on the repository; every fine-grained token and GitHub App has it. If GitHub refuses, the sign-in screen says access couldn't be checked and the server log says why. To choose who can sign in with GitHub, and with which role, set GRAFT_STUDIO_EDITORS:

GRAFT_STUDIO_EDITORS="ana@shop.example.com=admin, octocat, ben@shop.example.com=contributor"

Sign in with Google. In the Google Cloud console, open APIs & Services, Credentials, and create an OAuth client ID of type Web application, with the authorized redirect URI https://<your-site>/api/studio/v1/auth/google/callback. Then set:

VariableValue
GRAFT_GOOGLE_CLIENT_IDThe OAuth client's ID
GRAFT_GOOGLE_CLIENT_SECRETIts client secret
GRAFT_STUDIO_URLThe public origin, such as https://shop.example.com

Google sign-in follows the rules for single sign-on below: invite someone's Google email in Settings, People, and they press "Continue with Google".

Single sign-on with another provider (OIDC). For a provider your team already uses: Okta, Microsoft Entra ID, Keycloak, your app's own Better Auth and others. Register Studio with the provider as a web application whose redirect URI is https://<your-site>/api/studio/v1/auth/oidc/callback, then set:

VariableValue
GRAFT_OIDC_ISSUERThe provider's issuer URL, the one its tokens carry in iss. Studio reads <issuer>/.well-known/openid-configuration
GRAFT_OIDC_CLIENT_IDThe client ID the provider gave Studio
GRAFT_OIDC_CLIENT_SECRETIts client secret
GRAFT_OIDC_NAMEOptional. The provider's name on the button, as in "Continue with Okta". Default "Continue with single sign-on"
GRAFT_STUDIO_URLThe public origin, such as https://shop.example.com

Set the first three together. Studio refuses to start with only some of them, and with an issuer that isn't https (plain http works only on localhost).

With Google or another provider, the provider says who someone is; Studio decides whether they get in. It reads the person's email from the ID token, or from the provider's userinfo endpoint, and only an email the provider marks verified counts. Then:

  • Someone with a Studio account under that email signs in as that account, with its role. Removing them in Settings, People keeps them out, whatever the provider says.
  • Someone invited from Settings, People gets their account on their first single sign-on, with the role they were invited with. To add a person, invite their email and tell them to press the button; there is no link to pass on and no password to choose, and Settings shows them no password to change. If they want one later, an admin makes them a link to set it.
  • Someone whose verified email is at a domain in GRAFT_STUDIO_AUTO_JOIN joins on their own, with the domain's role. See below.
  • Someone without an account or an invitation gets in only if GRAFT_STUDIO_EDITORS lists their email, with the role it gives.
  • Anyone else is refused, and the sign-in screen says to ask for an invitation.

Let a whole domain join. To skip inviting everyone at your company, name its email domain, with the role new people get:

GRAFT_STUDIO_AUTO_JOIN="shop.example.com=contributor, agency.example=viewer"

The first time someone signs in with GitHub, Google or single sign-on and has a verified email at one of those domains, Studio makes them an account with that role. The role defaults to contributor. They show up in Settings, People like anyone invited, where an admin can change their role or remove them, and someone removed stays out. People also lists each domain that joins on its own. A few things are refused so the door stays narrow:

  • Admin. Nobody joins as an admin on their own; invite admins.
  • Free mail providers such as gmail.com or outlook.com, where anyone can make an address.
  • Joining before the owner exists. The first visit still creates the owner.

GitHub counts any verified address on someone's GitHub account, so a person whose work email is a second address there still joins.

Studio uses the authorization code flow with PKCE, and checks the ID token's signature, issuer, audience, expiry and nonce. If a sign-in fails, the screen says it didn't complete and the server log says why.

To sign in with your app's Better Auth, add its OAuth provider plugin (Better Auth 1.7 replaced the old OIDC provider plugin with it):

import { oauthProvider } from "@better-auth/oauth-provider";
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";

export const auth = betterAuth({
  // ...your database and sign-in methods, as before
  disabledPaths: ["/token"],
  plugins: [jwt(), oauthProvider({ loginPage: "/sign-in", consentPage: "/consent" })],
});

Register Studio once, as a signed-in user of your app. skip_consent spares your team a consent screen for your own tool:

const studio = await auth.api.adminCreateOAuthClient({
  headers, // the signed-in user's request headers
  body: {
    client_name: "Graft Studio",
    redirect_uris: ["https://shop.example.com/api/studio/v1/auth/oidc/callback"],
    skip_consent: true,
  },
});

Put studio.client_id and studio.client_secret in GRAFT_OIDC_CLIENT_ID and GRAFT_OIDC_CLIENT_SECRET. The issuer is your app's Better Auth base URL, such as https://shop.example.com/api/auth. Better Auth only lets a client registered as application_type: "native" use an http redirect on localhost, so register that way to try it out before Studio is on https.

Command-line invite links sign someone in without an account:

graft studio invite ana@shop.example.com --name "Ana Lima" --role editor

This prints a link that signs Ana in. It lasts seven days (--days sets 1 to 30). Send it privately: anyone with the link can use it until it expires. Commits from an invited editor carry their name and email. An invite link grants the role it was made with on its own: GRAFT_STUDIO_EDITORS applies to GitHub, Google and single sign-on only, so an invited person does not need to be on that list.

Roles

RoleCan
viewerRead everything
contributorDraft changes. Publishing opens a pull request
editorDraft and publish
adminEverything an editor can, approve or deny actions waiting for a person, and manage people

3. Deploy

Studio is served by graft serve --studio, the same server that answers /api/fn, /api/mcp and the content API. Run it wherever you run Node: the container image, Fly, Railway or Render. The content directory can be read-only; nothing writes to it.

GRAFT_STUDIO=1 graft serve --host 0.0.0.0

Studio is then at https://<your-site>/studio. Sign-in needs that https address: the session cookie is Secure, so a browser drops it on plain http anywhere but localhost, and Studio says so instead of showing the form. To try sign-in without https, open Studio at http://localhost on the machine that runs it.

Set GRAFT_DEPLOYED_SHA to the commit the deployment was built from, if your host does not already provide one (Vercel, Render and Railway do). Studio uses it to show entries published since the build, before the redeploy lands.

examples/shop in the repository is a product catalog set up this way.

4. Publish without a developer

When an editor publishes, the production branch moves and your host rebuilds the site from it, running graft compile as part of the build like any other deploy. There is no separate content pipeline to keep in sync.

Agents use the same drafts

On a server writing to GitHub, MCP's write_content and delete_content save to the connection's own draft branch, and list_drafts, publish_drafts and discard_drafts do what Studio's Publish sheet does. An agent opens a pull request unless its token carries content:publish. See MCP tools.

Give each agent its own token from Developer, Agents in Studio. Pick Read, Write drafts or Publish; only Publish carries content:publish. The agent sends the token to /api/mcp as Authorization: Bearer graft_at_…. Revoke it there and the agent is refused from its next request. See Studio, Agents.

The same token works from a terminal or a CI job. graft login <url> saves it, then graft content commands with --remote, and graft api, act on the hosted site through the same endpoints. See Work with a hosted site.

To rebuild or refresh the site after a publish, add a deploy hook under Settings, Deploy hooks. It is called after a publish of content in the database, and after an agent's commit to the production branch. See Studio, Site and deploy hooks.

Check it works

  1. Open /studio. You see the sign-in screen.
  2. Sign in, open an entry and change a field. The bar says "Unpublished changes", and a branch called graft-studio/drafts/<you> appears on GitHub with your commit.
  3. Press Publish. One commit appears on the production branch, authored by you, and your draft branch no longer holds changes.
  4. Your host redeploys, and the change is live.

examples/shop/scripts/e2e-hosted.mjs runs the same steps against a stand-in for GitHub, with the content directory read-only, and fails loudly if any step does not hold.