% cd ..

Deploying Keystatic to Vercel Preview

Deploying Keystatic to Vercel Preview

For local dev only (next dev), the setup from the previous post on local setup is all you need. But I also wanted to edit posts from mobile or while out and about (honestly, this was the real reason I got into Keystatic in the first place).

So I set things up to run Keystatic on Vercel too. Even after finishing the local setup, there was a lot to think through around the production environment and authentication, so I'll walk through it step by step.

Switching storage modes

Keystatic's storage has three modes: local, github, and cloud. Local dev uses local mode (editing files directly on the filesystem), but on Vercel we switch to github mode (committing to the repo via the GitHub API).

storage: process.env.NODE_ENV === "development"
  ? { kind: "local" }
  : { kind: "github", repo: "dazydayz/dazy-blog" }

On Vercel, the main branch is production, but other branches can be shown in Preview mode. In my repo, I use a (unimaginatively named) preview branch to show preview deployments on Vercel.
Since what I mainly want to edit in Keystatic are unpublished draft posts, the ideal setup is to make Keystatic available on Preview and disable it in production. Since I only want it enabled on Preview, the intuitive branch is on VERCEL_ENV === "preview" — but that's a trap. The trick, as shown above, is to use NODE_ENV for the check.

keystatic.config.ts gets evaluated on both the server and the client. VERCEL_ENV isn't exposed to the browser unless it has Next.js's NEXT_PUBLIC_* prefix, so on the client side in Preview, process.env.VERCEL_ENV is undefined. The result: the server generates routes in github mode, but the client assumes local mode and hits /api/keystatic/tree, which returns 404 Not Found — exactly the mismatch that bit me.

NODE_ENV gets inlined by Next.js at build time on both sides, so the value stays consistent everywhere. Keystatic's docs also use NODE_ENV-based checks as the standard pattern.

Block /keystatic in production

As mentioned above, github mode would also run in production, so we block the production /keystatic URL with a 404 in middleware.

// middleware.ts
export function middleware(request: NextRequest) {
  if (process.env.VERCEL_ENV === "production") {
    const url = request.nextUrl.clone();
    url.pathname = "/not-found";
    return NextResponse.rewrite(url, { status: 404 });
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/keystatic/:path*", "/api/keystatic/:path*"],
};

Middleware runs server-side, so VERCEL_ENV works properly here. Splitting logic with keystatic.config.ts meant I had to stay aware of where the code actually runs — server or client.

Returning JSON with NextResponse.json({ error: "Not Found" }) would also work, but I wanted users who stumble in by accident to see a normal 404 page, so I used rewrite to send them to the /not-found route instead.

Vercel protects the Preview environment automatically

Letting anyone access Preview would be a problem, so at first I added Basic Auth in middleware. But Vercel's Hobby plan has Deployment Protection (Vercel Authentication) enabled by default, and Preview URLs can't be accessed at all without a Vercel account login. That means the only accounts that can get through auth are ones with permissions on the project, so on the Hobby plan it effectively becomes a "Preview environment for me only."

In other words, Vercel was already taking care of Preview protection from the start, and my homegrown Basic Auth was just a redundant second layer. I ended up ripping out all the Basic Auth code — all I actually needed was to block access in production.
This is probably a common first-time pitfall, but knowing Vercel's defaults ahead of time would have saved me the detour.

GitHub App setup

Keystatic's github mode needs a GitHub App to commit to the repo via the GitHub API. The reason for using a GitHub App rather than an OAuth App is that Apps allow narrower scope (e.g. Contents only, on specific repos).

The basic steps:

  1. Create a GitHub App at https://github.com/settings/apps/new
  2. Register the Preview URL's callback path as the Callback URL
  3. Repository permissions > Contents: Read and write
  4. After creating, take note of the Client ID and Client Secret
  5. Set the following in Vercel's environment variables (both Preview and Production):
    KEYSTATIC_GITHUB_CLIENT_ID
    KEYSTATIC_GITHUB_CLIENT_SECRET
    KEYSTATIC_SECRET (for session encryption, a random string of 32+ characters)
    Preview is where Keystatic actually runs, so it needs real values. Production blocks access, so technically they aren't needed — except the Keystatic API route's config validation runs at build time, so the env vars need to exist (dummies are fine) — a real gotcha. Without them, the production build fails.
    Dummy values (random 32+ characters) are fine for Production (for defense-in-depth, deliberately using different values from Preview gives some peace of mind).
  6. The official Keystatic docs list a fourth env var, NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG, but it doesn't seem necessary for basic functionality — this blog runs fine without it.
  7. Install your newly created GitHub App on the target repository (important — see the next section)

Authorize and Install are different things

The last thing that tripped me up with the GitHub App was the distinction between two similar-but-distinct concepts:

  • Authorize: the user issues an OAuth token to the App
  • Install: the repo owner grants the App access to the repo

Logging into Keystatic triggers GitHub's Authorize prompt, and it feels like that's all there is to it — but without Install, the App can't read or write the repo. The result: OAuth authentication succeeds, but subsequent calls like fetching collections return 500 errors.

In short, just creating the App isn't enough — it has to actually be installed.
Install status is visible under GitHub Settings → Applications → Installed GitHub Apps. If it's not listed, go to the App's page and click "Install App."

KEYSTATIC_SECRET needs 32+ characters

Setting a shorter string returns a 500 with KEYSTATIC_SECRET must be at least 32 characters long.

openssl rand -hex 32       # 64 hex characters
openssl rand -base64 32    # 44 Base64 characters

It doesn't have to be hex — anything random with 32+ characters works. Once set, it's safer not to rotate it (changing it invalidates existing sessions).

Use the Preview branch URL for the Callback URL

Vercel Preview deployments have two kinds of URLs:

  • Branch URL: dazy-blog-git-preview-dazydayzs-projects.vercel.app (fixed to the preview branch)
  • Deployment-specific URL: dazy-blog-abc123-dazydayzs-projects.vercel.app (changes per commit)

GitHub App Callback URLs don't support wildcards, and the registered URL has to match the OAuth request URL exactly. The branch URL is stable, so I registered only that one and made it a rule that "Keystatic is always accessed via the branch URL."

You can't make Keystatic open on the preview branch by default

Opening Keystatic on Vercel Preview to edit something commits to the default branch (main, in this case) by default. Even though I'm running it in Preview, it was committing directly to the production branch — I didn't notice at first and polluted main.

I checked the type definitions for keystatic.config.ts's storage and confirmed that only pathPrefix and branchPrefix can be set — there's no option for "which branch to select initially". Keystatic always defaults to the repo's default branch.

What I want to doHow to do it
Set Keystatic's initial branch to previewNot possible in config
Switch to a specific branchPick from the BranchPicker in the UI
Open preview from the startInclude /keystatic/branch/preview in the URL

For now, my workaround is to bookmark https://<preview-url>/keystatic/branch/preview and always enter from there, to avoid polluting main. Switching from the BranchPicker UI works too, but the default behavior of "open Keystatic = pointed at main" is an accident waiting to happen.

Wrapping up

Here's where I ended up with access setup.

Environmentstorage/keystatic accessAuth
Local (next dev)localOK (direct file edits)None
Vercel PreviewgithubOK (select preview in BranchPicker)Vercel login + GitHub App OAuth
Vercel Productiongithub (unused)Blocked with 404 in middleware

Having production's storage set to github feels a bit uncomfortable, but since middleware blocks the entrance entirely, it's unreachable. I considered defaulting storage to local in production as defense in depth, but as mentioned earlier, the client/server env var mismatch problem made me give up on that idea.

Getting Keystatic running on Vercel required a bit of everything — GitHub App setup, env var separation, middleware-based access control — a fairly comprehensive knowledge of the Next.js + Vercel ecosystem. There are plenty of traps that slip past when following only the official docs (NODE_ENV vs VERCEL_ENV, Authorize vs Install, the default branch issue), and the difficulty level jumps up a notch compared to the simplicity of the local setup.

Still, the effort paid off, and I got what I originally wanted: an environment where I can edit blog posts from a browser, from mobile or on the go. Thanks to Vercel's Deployment Protection, I get auth for free too, so even on the Hobby plan it works as a practical, personal Web CMS.

That pretty much wraps up my Keystatic tryout — though it turns out that using it alongside Front Matter CMS exposes another set of pitfalls (Markdoc dialect round-tripping, IndexedDB draft semantics, the SPA cache swallowing external edits). I've collected those in a separate post: Using Front Matter CMS and Keystatic Together Reveals a Design Philosophy Clash.