DEV Community

Yuuki Yamashita
Yuuki Yamashita

Posted on • Originally published at marp-ai-app.vercel.app

Building a chat-based Marp slide generator with Next.js, Amazon Bedrock, and Lambda

Overview

I built an app that turns natural language into Marp slides through a chat interface.

You can ask "Make 8 slides on AI agent use cases" and the app generates the deck. Then keep chatting: "Make slide 3 more concise" and it edits in place. Exports to PDF and editable PPTX. There is also a "My Themes" feature that lets you paste any Marp CSS, save it to localStorage, and switch on the fly.

This post is a tour of the architecture and the rough edges I hit.

Architecture

Browser
   ↓
Next.js 16 (Vercel) ─ /api/generate → Amazon Bedrock (Sonnet 4.6, us-east-1)
                    └ /api/export   → Lambda (arm64, ap-northeast-1)
                                       ├ marp-cli + Chromium     → PDF
                                       └ marp-cli + LibreOffice  → editable PPTX
                                       └ S3 (1-day lifecycle)     → presigned URL
Auth: Vercel OIDC Federation → IAM Role (keyless, no access keys)
Enter fullscreen mode Exit fullscreen mode
Role Tech
Frontend / API Next.js 16 (App Router, Route Handlers)
Hosting Vercel
LLM Amazon Bedrock Converse API (us.anthropic.claude-sonnet-4-6, us-east-1)
Export AWS Lambda (container, arm64, ap-northeast-1)
File delivery Amazon S3 + presigned URLs (1-day lifecycle)
Auth Vercel OIDC Federation → IAM Role (keyless)
Preview marp-core (rendered in the browser)

1. Multi-turn chat with Bedrock Converse

I wanted a plain chat experience — user message, assistant reply, follow-up edits — so I pass the conversation directly to the Bedrock Converse API's messages[].

Message shape

type ChatMessage = {
  id: string;
  role: "user" | "assistant";
  content: string;
  markdown?: string;
};
Enter fullscreen mode Exit fullscreen mode

Each assistant message holds both content (the short conversational reply) and markdown (the Marp deck). When sending to the API, I concatenate them into a single message:

function buildApiMessages(messages: ChatMessage[]): ApiMessage[] {
  return messages.map((m) => ({
    role: m.role,
    content:
      m.role === "assistant" && m.markdown
        ? `${m.content}\n\n${m.markdown}`.trim()
        : m.content,
  }));
}
Enter fullscreen mode Exit fullscreen mode

System prompt: reply first, then Markdown

I want both a chat reply and a Marp deck back, so the system prompt enforces the format:

- Start with a 1-2 sentence natural reply.
- If generating or editing slides, follow the reply (no blank line) with Marp Markdown.
- Never wrap the Markdown in code fences (```

).
- On edit requests, re-output the entire deck (no diffs, no patches).


Enter fullscreen mode Exit fullscreen mode

The server splits on the first ---\nmarp: occurrence:


ts
function parseResponse(text: string): { message: string; markdown: string } {
  const trimmed = text.trim();
  let idx = trimmed.search(/(?:^|\n)---\s*\n\s*marp:/);
  if (idx === -1) idx = trimmed.search(/(?:^|\n)---\s*\n/);
  if (idx === -1) return { message: trimmed, markdown: "" };
  const splitAt = idx === 0 ? 0 : idx + 1;
  return {
    message: trimmed.slice(0, splitAt).trim(),
    markdown: trimmed.slice(splitAt).trim(),
  };
}


Enter fullscreen mode Exit fullscreen mode

Full-deck re-outputs are easier to keep coherent than diffs (frontmatter, page breaks, closing slide all stay consistent). Since the chat history is already in Bedrock's context, vague follow-ups like "shorter please" still work.

2. Making preview and export look the same

Marp themes are CSS. The challenge:

  • Preview: marp-core running in the browser
  • Export: marp-cli running in Lambda

If the theme CSS or font override drifts between the two, you get the classic "preview looks fine but the exported file looks different" bug.

One source of truth for theme CSS

src/lib/themes.ts defines six custom theme CSS strings. Both sides consume them:


ts
// Preview side (marp-core)
for (const t of CUSTOM_THEMES) {
  if (t.css) marp.themeSet.add(t.css);
}

// Export side (Lambda: marp-cli)
if (themeCss) {
  const themePath = path.join(work, "theme.css");
  await writeFile(themePath, themeCss, "utf8");
  args.push("--theme-set", themePath);
}


Enter fullscreen mode Exit fullscreen mode

Font override via a <style> block in the Markdown

Marp treats <style> tags inside the Markdown as deck-wide CSS. Inject the same override into both marp-core and marp-cli:


ts
export function fontStyleBlock(font: FontId): string {
  const stack = getFontStack(font);
  if (!stack) return "";
  return `<style>
section,
section :is(h1, h2, h3, h4, h5, h6, p, li, blockquote, th, td, a, strong, em) {
  font-family: ${stack} !important;
}
</style>`;
}


Enter fullscreen mode Exit fullscreen mode

3. User-defined themes (localStorage)

I wanted to let users paste any Marp CSS and use it as a theme. The trick is making sure the /* @theme name */ header is always present — if the user forgot, generate one from the label and prepend it:


ts
export function ensureThemeHeader(
  css: string,
  fallbackLabel: string,
): { name: string; css: string } {
  const existing = parseThemeName(css);
  if (existing) return { name: existing, css };
  const name = slugifyThemeName(fallbackLabel);
  return { name, css: `/* @theme ${name} */\n${css}` };
}


Enter fullscreen mode Exit fullscreen mode

Validate against builtin names → save to localStorage → list under a "My Themes" optgroup → on export, send the CSS via userThemeCss so Lambda's existing --theme-set path picks it up. No Lambda changes required.

4. Lambda container: Chromium + LibreOffice in one box

One Lambda handles both formats:

  • marp-cli + Chromium → PDF (Marp's default path)
  • marp-cli + LibreOffice → editable PPTX (--pptx --pptx-editable)

Three Lambda-specific gotchas worth flagging.

Chromium dies with Connection closed

Lambda has a read-only filesystem, tiny /dev/shm, and forbids sandboxing — Chromium's zygote/multi-process model falls over. Marp doesn't expose low-level browser args, so route through a wrapper script via --browser-path:


dockerfile
RUN printf '#!/bin/sh\nexec /usr/bin/chromium \
  --no-sandbox --disable-dev-shm-usage --disable-gpu \
  --disable-software-rasterizer --single-process --no-zygote "$@"\n' \
  > /usr/local/bin/chromium-marp \
  && chmod +x /usr/local/bin/chromium-marp
ENV CHROME_PATH=/usr/local/bin/chromium-marp


Enter fullscreen mode Exit fullscreen mode

marp-cli hangs waiting on stdin

In Lambda's child_process.spawn, stdin is an open pipe by default, and marp blocks waiting for input. Always pass --no-stdin:


js
const args = ["--no-stdin", "--allow-local-files"];


Enter fullscreen mode Exit fullscreen mode

M PLUS Rounded 1c font family name mismatch

The TTF's internal family name is Rounded Mplus 1c, so listing only 'M PLUS Rounded 1c' in CSS won't match. List both:


css
font-family: 'M PLUS Rounded 1c','Rounded Mplus 1c',sans-serif;


Enter fullscreen mode Exit fullscreen mode

5. Multi-stage build: 3.18 GB → 2.39 GB

My first Dockerfile was a single stage at 3.18 GB. The culprit: the toolchain needed for aws-lambda-ric's native build (g++ / cmake / automake / python3 / libcurl4-openssl-dev) was still in the runtime image.

Split into three stages — runtime dropped to 2.39 GB (-790 MB, -25%):


dockerfile
# 1. Font fetcher
FROM debian:bookworm-slim AS fonts
RUN apt-get install -y --no-install-recommends curl ca-certificates
COPY install-fonts.sh /tmp/
RUN sh /tmp/install-fonts.sh

# 2. node_modules builder (aws-lambda-ric native build)
FROM node:22-bookworm-slim AS builder
RUN apt-get install -y --no-install-recommends \
      g++ make cmake autoconf automake libtool pkg-config python3 \
      libcurl4-openssl-dev ca-certificates
COPY package.json ./
RUN npm install --omit=dev && npm cache clean --force

# 3. Runtime
FROM node:22-bookworm-slim AS runtime
RUN apt-get install -y --no-install-recommends \
      chromium libreoffice-impress fonts-noto-cjk fonts-noto-color-emoji fontconfig
COPY --from=fonts /usr/share/fonts/truetype/marpfonts /usr/share/fonts/truetype/marpfonts
RUN fc-cache -f /usr/share/fonts/truetype/marpfonts
COPY --from=builder /build/node_modules ./node_modules


Enter fullscreen mode Exit fullscreen mode

Key points:

  • fonts stage uses curl to grab 7 font families — runtime never sees curl
  • builder stage owns the toolchain; only node_modules is copied to runtime
  • Runtime only carries chromium + libreoffice-impress + fonts-noto-cjk + fontconfig

6. Keyless AWS from Vercel via OIDC Federation

I didn't want to store AWS access keys in Vercel env vars. Vercel's OIDC Federation lets you exchange a short-lived token for AWS credentials.

The IAM trust policy locks scope and project:


json
{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::761018866498:oidc-provider/oidc.vercel.com/yuuki-yamashitas-projects"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "oidc.vercel.com/yuuki-yamashitas-projects:aud":
        "https://vercel.com/yuuki-yamashitas-projects",
      "oidc.vercel.com/yuuki-yamashitas-projects:sub":
        "owner:yuuki-yamashitas-projects:project:marp-ai-app:environment:production"
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

On the Next.js side, @vercel/functions/oidc does the heavy lifting:


ts
import { awsCredentialsProvider } from "@vercel/functions/oidc";

export function awsCredentials() {
  if (process.env.VERCEL) {
    return awsCredentialsProvider({
      roleArn: process.env.AWS_ROLE_ARN!,
    });
  }
  return undefined; // local: default AWS credential chain
}


Enter fullscreen mode Exit fullscreen mode

Locally I fall back to ~/.aws/credentials via the default chain.

Wrap-up

  • Multi-turn chat editing falls out of Bedrock Converse messages[] if you let the model re-output the full deck on every turn
  • Match preview and export by sharing theme CSS and injecting font overrides through a <style> block in the Markdown
  • For Chromium + LibreOffice in one Lambda: --single-process --no-zygote --no-sandbox and --no-stdin are non-negotiable
  • Multi-stage build saved 790 MB
  • Vercel → AWS goes through OIDC Federation, no keys involved

Repo: yama3133/marp-ai-app
Live: marp-ai-app.vercel.app

Feedback welcome.

Top comments (0)