Cookbooks

Next.js chat app

A Next.js App Router chat endpoint that recalls a signed-in user's history from Memsy before answering, and records the turn afterwards.

A chat UI that forgets the user between visits feels broken in a way that no amount of prompt engineering fixes. This recipe adds Memsy to a Next.js App Router app so the assistant opens every session already knowing what the user told it last week.

The integration point that matters here is identity: your auth system's user id becomes Memsy's actor_id. Get that mapping right and per-user memory is three lines. Get it wrong — a shared id, a client-supplied id — and every user reads every other user's memories.

Install dependencies

npm install @memsy-io/memsy openai

Node 18 or later (the SDK uses the global fetch). This recipe assumes an existing Next.js 14+ app using the App Router.

One step below reaches outside that list: @vercel/functions, for waitUntil in Step 6.

npm install @vercel/functions

Nothing here depends on a particular auth library. The route needs a user id it can trust, and Step 3 is where you supply one.

Set your credentials

# .env.local — gitignored by default in a create-next-app project
MEMSY_BASE_URL="https://api.memsy.io/v1"
MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OPENAI_API_KEY="sk-..."

Warning

Do not prefix these with NEXT_PUBLIC_. That prefix inlines the value into the browser bundle, which would publish your Memsy key to every visitor.

Create a shared client module

One client per server process, not one per request — the SDK holds a connection pool, and rebuilding it on every keystroke-driven request wastes handshakes.

// lib/memsy.ts
import { MemsyClient } from "@memsy-io/memsy";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env var: ${name}`);
  return value;
}

// Module scope: Next reuses the module across requests in a warm server.
export const memsy = new MemsyClient({
  baseUrl: required("MEMSY_BASE_URL"),
  apiKey: required("MEMSY_API_KEY"),
});

Tip

Throwing on a missing variable at import time turns a silent production misconfiguration into a loud boot failure. A missing MEMSY_API_KEY would otherwise surface as a 401 on the first user message.

Say who the user is

This is the only part of the recipe that depends on your stack, so it gets its own file and the handler never mentions your auth library.

// auth.ts
/**
 * The current user's id, or null if nobody is signed in.
 *
 * This is the one function to replace. Two requirements, and they are the
 * whole security model of the recipe:
 *
 *   1. It runs on the SERVER. A client-supplied id is a field the client
 *      can edit, and editing it reads someone else's memories.
 *   2. The id is STABLE. It becomes `actor_id`, so a value that changes
 *      orphans everything recorded under the old one.
 */
export async function currentUserId(_req: Request): Promise<string | null> {
  throw new Error(
    "currentUserId() is not wired up yet — return your session's user id.",
  );
}

Warning

It throws on purpose. The obvious placeholder — returning "user_123" so the thing runs — is the version that reaches production, and when it does every user shares one actor and reads everyone else's memories. There is no error to notice, only memories that seem oddly well informed. Failing loudly on the first request costs a minute; the placeholder costs a migration.

Replace the body with whatever you already run:

AuthBody of currentUserId
Clerkconst { userId } = await auth(); return userId; — also needs clerkMiddleware(), or auth() throws instead of returning null
Auth.js / NextAuthconst session = await auth(); return session?.user?.id ?? null;
Supabaseconst { data } = await supabase.auth.getUser(); return data.user?.id ?? null;
Auth0const session = await getSession(); return session?.user?.sub ?? null;
Your own cookiewhatever your session lookup returns

Note

Clerk is the fiddliest of these because auth() reads a context that only clerkMiddleware() establishes — without that middleware it throws rather than returning null, so the route answers 500 where you expect 401. If that is your stack, add the middleware at the project root and make sure its matcher covers /api/(.*).

Write the route handler

Three moves: recall, ground, record. The recall and the LLM call are awaited because the user is waiting on them; the record is not, because they are not.

// app/api/chat/route.ts
import { currentUserId } from "@/auth";
import OpenAI from "openai";
import { memsy } from "@/lib/memsy";

const openai = new OpenAI();

export async function POST(req: Request) {
  // The actor id comes from the server-side session — NEVER from the
  // request body. A client-supplied actor_id lets any user read any
  // other user's memories by editing one JSON field.
  const userId = await currentUserId(req);
  if (!userId) {
    return new Response("Unauthorized", { status: 401 });
  }

  const { message, sessionId } = (await req.json()) as {
    message: string;
    sessionId: string;
  };

  // 1. Recall — what has this user told us before that bears on this?
  const recalled = await memsy.search(message, {
    actorId: userId,
    limit: 5,
  });

  const context = recalled.results.length
    ? recalled.results.map((r) => `- ${r.content}`).join("\n")
    : "(nothing on file for this user yet)";

  // 2. Ground.
  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [
      {
        role: "system",
        content:
          "You are a helpful assistant with a long memory. Use the facts " +
          "below when they are relevant, and refer to them naturally — " +
          "do not announce that you are consulting your memory.\n\n" +
          `What you know about this user:\n${context}`,
      },
      { role: "user", content: message },
    ],
  });

  const reply = completion.choices[0]?.message?.content ?? "";

  // 3. Record — both sides of the turn, in one batch.
  await memsy.ingest([
    {
      actorId: userId,
      sessionId,
      kind: "user_message",
      content: message,
    },
    {
      actorId: userId,
      sessionId,
      kind: "assistant_message",
      content: reply,
    },
  ]);

  return Response.json({ reply });
}

Keep ingest off the response path

The version above awaits ingest() before replying, which adds a round trip the user can feel. Memsy processes events asynchronously anyway — the call returns as soon as the events are queued, so there is nothing to wait for.

On Vercel, use waitUntil so the work survives the response:

import { waitUntil } from "@vercel/functions";

// ... after building `reply`:
waitUntil(
  memsy
    .ingest([
      { actorId: userId, sessionId, kind: "user_message", content: message },
      { actorId: userId, sessionId, kind: "assistant_message", content: reply },
    ])
    // A failed write must not take down a reply the user already sees.
    .catch((err) => console.error("[memsy] ingest failed", err)),
);

return Response.json({ reply });

Warning

A bare floating promise without waitUntil is not safe in a serverless runtime: the function may be frozen the moment the response is returned, dropping the request mid-flight. The events are lost silently, and you find out weeks later when recall is thinner than it should be.

Choose a session id

A session is one continuous conversation. Memsy uses the boundary to decide what belongs together, so the id should change when the conversation does — and not before.

// A stable id per chat thread, created when the thread is created.
const sessionId = `chat_${threadId}`;
ChoiceEffect
New id per messageEvery turn is an island; multi-turn context is lost
One id forever, per userUnrelated conversations blur together
One id per threadWhat you want

actor_id is who; session_id is which conversation. See Actors and sessions for the full model.

Call it from a client component

"use client";
import { useState } from "react";

export function Chat({ threadId }: { threadId: string }) {
  const [reply, setReply] = useState("");

  async function send(message: string) {
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      // No actor id here — the server reads it from the session.
      body: JSON.stringify({ message, sessionId: `chat_${threadId}` }),
    });

    // Check res.ok before parsing. A 401 returns the plain text
    // "Unauthorized", so res.json() would throw while parsing it and the
    // button would appear to do nothing — which is exactly what a reader
    // hits first, before they are signed in.
    if (!res.ok) {
      setReply(`Request failed (${res.status}).`);
      return;
    }

    const data = await res.json();
    setReply(data.reply);
  }

  // Render `reply`. A component that stores an answer and returns only the
  // button is a component whose button appears to do nothing -- on the 401
  // and on the 200 alike.
  return (
    <>
      <button onClick={() => send("What did we decide about pricing?")}>Ask</button>
      {reply && <p>{reply}</p>}
    </>
  );
}

Verifying it works

Memory extraction is asynchronous, so a fact ingested a second ago is not searchable a second later. Send one message, wait, then search from a script:

const hits = await memsy.search("pricing", { actorId: "user_abc", limit: 5 });
console.log(hits.results.map((r) => `${r.score.toFixed(2)} ${r.content}`));

An empty result immediately after ingest is expected, not a bug — see Async processing for the timing, and status() if you need to know when a specific event has landed.

What to watch for

  • Rebuilding the client per request. new MemsyClient(...) inside the handler works, but discards the connection pool every time. Module scope, as above.
  • Trusting a client-supplied actorId. The single highest-severity mistake in this recipe. It must come from the server-side session.
  • Awaiting ingest() on the hot path. Costs the user latency for a write they never see the result of.
  • Searching org-wide by accident. Omitting actorId searches across every actor in your org. That is a deliberate feature for admin tooling, and wrong in a user-facing chat.

Complete code

Four files, ready to paste into an App Router project. The route handler is the Step 6 form — ingest deferred with waitUntil — since that is the version you want in production.

Warning

auth.ts ships throwing, and it is the one file you must edit. Give it your session lookup before anything works — see the swap table in Say who the user is.

memsy.ts
// lib/memsy.ts
import { MemsyClient } from "@memsy-io/memsy";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env var: ${name}`);
  return value;
}

// Module scope: Next reuses the module across requests in a warm server.
export const memsy = new MemsyClient({
  baseUrl: required("MEMSY_BASE_URL"),
  apiKey: required("MEMSY_API_KEY"),
});

Set up:

npm install @memsy-io/memsy openai @vercel/functions
# .env.local
MEMSY_BASE_URL="https://api.memsy.io/v1"
MEMSY_API_KEY="msy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OPENAI_API_KEY="sk-..."

Plus whatever your auth needs — Clerk wants NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY, Supabase wants its URL and key, and so on. None of it is Memsy's business.

Warning

The only hard requirement is that currentUserId() resolves on the server. Everything else about your auth is interchangeable; that one property is what stops a user reading another user's memories.

Next steps