App shapes

Publish a Next.js app

Write normal Next.js App Router code. Use Ellivate's KV SDK instead of a database. Don't install NextAuth or Clerk — the shell gives you an authenticated viewer. Run `ellivate publish`.

The shortest Next.js app

A single-page app that reads and writes a shared list. Save as app/page.tsx:

import { ellivate } from "../ellivate-client";
import { revalidatePath } from "next/cache";

export default async function Home() {
  const items = (await ellivate.kv.get<string[]>("shared:items")) ?? [];

  async function add(formData: FormData) {
    "use server";
    const item = formData.get("item") as string;
    const current = (await ellivate.kv.get<string[]>("shared:items")) ?? [];
    await ellivate.kv.set("shared:items", [...current, item]);
    revalidatePath("/");
  }

  return (
    <main>
      <h1>Our list</h1>
      <ul>{items.map((i, n) => <li key={n}>{i}</li>)}</ul>
      <form action={add}>
        <input name="item" autoFocus />
        <button>Add</button>
      </form>
    </main>
  );
}

And a minimal package.json:

{
  "name": "my-list",
  "private": true,
  "scripts": {
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "14",
    "react": "18",
    "react-dom": "18"
  }
}

Run ellivate publish. That's the whole app.

What Ellivate does at publish time

1. Writes the SDK locally

ellivate-client.js and ellivate-client.d.ts land at your project root. Import with a relative path — "../ellivate-client" from any file in app/. Bare specifier "ellivate-client" would send npm ci to the registry looking for a package that doesn't exist.

2. Installs the auth handoff route

A route handler at app/ellivate-handoff/route.ts is written on every publish. When a viewer opens your app through Ellivate's shell, the shell minted a short-lived viewer token; the handoff route lands the token as an HttpOnly cookie, then 307-redirects to the real page. That cookie is how your server components know who's watching.

3. Patches your middleware to let the handoff through

If you've written a middleware.ts, the translator injects an early-return for /ellivate-handoff so your own auth rules don't block the token-setting round-trip. Idempotent — re-runs add nothing.

4. Makes fetches dynamic

The SDK's fetch calls run with cache: "no-store". That's how the App Router knows not to try prerendering pages that read KV at build time, where the env vars aren't set.

Working with the SDK

In server components

The SDK reads the viewer from the handoff cookie via next/headers. Just call it:

import { ellivate } from "../ellivate-client";

export default async function Page() {
  const viewer = await ellivate.viewer();
  const items = await ellivate.kv.get<string[]>("shared:items");
  return <div>Hi {viewer?.username}</div>;
}

In server actions

"use server";
import { ellivate } from "../ellivate-client";

export async function addItem(formData: FormData) {
  await ellivate.kv.set(
    "shared:items",
    [...(await ellivate.kv.get<string[]>("shared:items") ?? []),
     formData.get("item")]
  );
}

In route handlers

import { ellivate } from "../../ellivate-client";
import { NextResponse } from "next/server";

export async function GET() {
  const items = await ellivate.kv.get("shared:items");
  return NextResponse.json(items ?? []);
}

Calling Ellivate data from a client component

The SDK is server-only in Next.js. The ellivate-client.js Ellivate writes imports next/headers to read the viewer cookie, so importing it into a "use client" component makes next build fail — Next.js refuses to ship next/headers to the browser. That's intentional, not a limitation: the canonical Next.js pattern is to keep data access on the server and let the client call a server action.

Wrongellivate.kv in a "use client" component. Fails the build:

"use client";
import { useEffect, useState } from "react";
import { ellivate } from "../ellivate-client"; // pulls in next/headers

export function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    // next build: "You're importing a component that needs next/headers"
    ellivate.kv.get<number>("shared:count").then((v) => setCount(v ?? 0));
  }, []);
  return <button>{count}</button>;
}

Right — the data call lives in a server action; the client component calls the action:

// app/actions.ts — server action. The SDK only runs here.
"use server";
import { ellivate } from "../ellivate-client";

export async function getCount() {
  return (await ellivate.kv.get<number>("shared:count")) ?? 0;
}

export async function bumpCount() {
  const next = ((await ellivate.kv.get<number>("shared:count")) ?? 0) + 1;
  await ellivate.kv.set("shared:count", next);
  return next;
}
// app/counter.tsx — client component calls the actions, never the SDK.
"use client";
import { useEffect, useState } from "react";
import { getCount, bumpCount } from "./actions";

export function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    getCount().then(setCount);
  }, []);
  return (
    <button onClick={async () => setCount(await bumpCount())}>
      {count}
    </button>
  );
}

Scopes and keys

Prefixes on the key control who sees the data:

await ellivate.kv.set("shared:items", [...]);  // everyone with access
await ellivate.kv.set("personal:draft", "...");  // just this viewer
await ellivate.kv.set("public:splash", "...");  // anyone, even anon

Unprefixed keys get classified automatically at publish time. Full details.

Things that will fail the publish gate

  • NextAuth, Clerk, Auth.js, Firebase Auth. Any third-party auth library. Ellivate's shell is the identity system.
  • /login, /signup, or /auth/* routes. You never need them — viewers arrive already signed in.
  • localStorage, IndexedDB, or redux-persist for user data. Use ellivate.kv so data follows users across devices.
  • Directly writing cookies for app state. Cookies are request-scoped and size-limited.
  • Routes under /_folder — Next.js App Router treats underscore-prefixed folders as private. The classifier catches these if you try to use one for a public route.

Debugging stale data

Server-rendered reads that return null when you expect data usually mean the handoff cookie didn't reach the request. Check:

  1. The cookie ellivate_viewer_token is present in the browser (DevTools → Application → Cookies → your app's domain).
  2. The Set-Cookie response includes Partitioned. Chrome blocks unpartitioned third-party cookies in iframes.
  3. Your middleware isn't redirecting /ellivate-handoff somewhere else before the cookie gets set. (Ellivate patches it at publish time, but a newly-added middleware wouldn't know yet — republish.)

What's next

  • The Ellivate contract — the full list of invariants the pipeline enforces.
  • Auth model — deeper dive on the handoff, the viewer token, and the caller-lane routing.