Minimal Next.js example that shows how to upload, send, and render image attachments in Plain chat and email threads from a headless customer portal.
This is the pattern to use when building a headless portal that needs two-way image support between customers and agents.
Demo only. There is no authentication. Anyone who can reach the app can list threads and send messages as the configured customer. Do not deploy this as-is.
Helpful reading first:
createAttachmentUploadUrl→ browser multipart upload to S3 →sendCustomerChat/replyToEmailwithattachmentIds- Polling a thread timeline for
ChatEntryandEmailEntryattachments createAttachmentDownloadUrl, including virus-scan gating before rendering- Recovering inline email images from
markdownContentwhenEmailEntry.attachmentsis empty - Listing a customer’s threads and creating a new chat thread from the UI
- Authenticate users and derive the customer (and allowed threads) from your session — not from env vars shared by everyone
- Scope every API route to the logged-in customer; validate attachment and thread ownership
- Prefer webhooks or server-sent events over short polling for agent replies
- Paginate long timelines
- A Plain workspace with GraphQL API access (headless portal entitlement for
sendCustomerChat) - A machine user + API key with:
attachment:create,attachment:download,chat:create,customer:read,email:create,email:read,thread:create,thread:read,timeline:read
- An existing customer ID (
c_…). Email reply testing also requires email to be enabled in the workspace.
cp .env.local.example .env.local
# Fill in PLAIN_API_KEY and PLAIN_CUSTOMER_ID
npm install
npm run devOpen http://localhost:3000.
Optional env vars:
| Variable | Purpose |
|---|---|
PLAIN_THREAD_ID |
Preselect a thread in the sidebar |
PLAIN_API_URL |
Override the GraphQL endpoint if your workspace is not on the default UK host |
Browser Next.js API Plain S3
| | | |
|-- POST /upload-url -------->|-- createAttachmentUploadUrl ->| |
|<-- form URL + fields -------|<-----------------------------| |
|-- multipart POST file --------------------------------------------------------------->|
|-- POST /messages/send ----->|-- sendCustomerChat / replyToEmail ---------------------->|
|-- GET /timeline ---------->|-- thread { timelineEntries } --------------------------->|
|-- POST /download-url ------>|-- createAttachmentDownloadUrl -------------------------->|
|<-- short-lived URL ---------|<-----------------------------| |
|-- GET image <---------------------------------------------------------------------------|
All GraphQL calls stay on the server. The browser only talks to your Next.js routes (and to Plain’s presigned S3 upload/download URLs).
| Route | Plain operation |
|---|---|
POST /api/attachments/upload-url |
createAttachmentUploadUrl |
POST /api/attachments/download-url |
createAttachmentDownloadUrl |
POST /api/messages/send |
sendCustomerChat or replyToEmail |
GET /api/threads |
threads filtered by customer |
POST /api/threads/create |
createThread + opening sendCustomerChat |
GET /api/threads/[threadId]/timeline |
thread + timelineEntries |
Email entries often return attachments: [] for pasted/inline images, with a text placeholder like [image: screenshot.png].
Plain still embeds the attachment ID in markdownContent:
This example decodes that payload and then uses createAttachmentDownloadUrl as usual. See attachmentsFromEmailMarkdown in lib/plain.ts.
app/api/ # BFF routes (API key never reaches the browser)
components/ # Thread list + chat UI
lib/attachments.ts # Shared types + isImageAttachment (safe for client)
lib/plain.ts # Server-only GraphQL client + timeline helpers
| Symptom | Likely cause |
|---|---|
| Mutation error naming a permission | Add the missing scope to the API key |
workspace_email_not_enabled |
Enable email in workspace settings before testing replyToEmail |
| Image stuck on “Scanning…” | Wait for attachmentVirusScanResult: CLEAN |
Warning about [image: …] with no ID |
markdownContent had no recoverable attachment ref |
- Keep
PLAIN_API_KEYin server env only (.env.localis gitignored) - Never commit real API keys or customer IDs