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 openaiNode 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/functionsNothing 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:
| Auth | Body of currentUserId |
|---|---|
| Clerk | const { userId } = await auth(); return userId; — also needs clerkMiddleware(), or auth() throws instead of returning null |
| Auth.js / NextAuth | const session = await auth(); return session?.user?.id ?? null; |
| Supabase | const { data } = await supabase.auth.getUser(); return data.user?.id ?? null; |
| Auth0 | const session = await getSession(); return session?.user?.sub ?? null; |
| Your own cookie | whatever 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}`;| Choice | Effect |
|---|---|
| New id per message | Every turn is an island; multi-turn context is lost |
| One id forever, per user | Unrelated conversations blur together |
| One id per thread | What 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
actorIdsearches 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.
- lib
- app
- api
- chat
- components
// 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
- Memory as an agent tool — let the model decide when to recall instead of prefetching every turn.
- Searching memory — thresholds, limits, and scoring.
- Error handling — the typed errors
ingest()andsearch()can raise.

