Skip to content

AI

Next.js boilerplate with AI bundle

Streaming chat, Zod-checked output and tool calling on the Vercel AI SDK.

The Vercel AI SDK on Anthropic or OpenAI, split into three sub-options you toggle independently. The provider choice picks the SDK package, the API key and the model catalogue. Each sub-option adds its own files, rules, skills and docs, and nothing in one sub-option imports another. Always generated: src/lib/ai/models.ts is the model allowlist for the chosen provider, and the only place a model id is named. src/lib/ai/provider.ts is its server-only façade. src/lib/ai/prompt.ts assembles the instructions, and src/lib/ai/untrusted.ts wraps untrusted content. src/lib/ai/messages.ts is the validated wire format and src/lib/ai/http.ts the shared error responses. src/lib/ai/access.ts decides who may call the AI routes: signed-in users when the repo has an auth battery, anyone when it does not. src/lib/ai/eval.ts, src/lib/ai/evals/** and scripts/ai-eval.ts are the prompt regression harness. scripts/ai-models.ts lists the models the app may call. Unit tests in tests/unit/ai-*.test.ts run on a mock model, so they need no key and cost nothing. Chat adds src/app/api/chat/route.ts, the /chat page and src/components/ai/chat.tsx (built on the component kit). With an auth battery the page lives in the signed-in app (src/app/(app)/chat/page.tsx, a Chat entry in the app sidebar and a card on the dashboard); without one it is a public page (src/app/chat/page.tsx) linked from the site header. Structured output adds src/lib/ai/structured.ts, src/lib/ai/schemas.ts and the ai-structured-output rule. Tool calling adds src/lib/ai/tools/**, src/lib/ai/knowledge-base.ts, src/lib/ai/agent.ts, src/app/api/agent/route.ts, the ai-tools rule and the add-tool skill. When a streaming route ships, vercel.json turns on request cancellation for it, so Stop really stops the upstream call on Vercel.

What AI bundle adds to the agent layer: 4 rules · 2 skills · 6 solution docs

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick AI bundle?

Pick it if

Any feature where the model reads something and writes something back: support triage, drafting, document extraction, a help assistant over your own content. Strongest when you want one provider abstraction now and the option to switch models later without touching call sites.

Watch out for

  • Unmetered on purpose. No usage counting, no per-user rate limit and no spend cap are generated. With an auth battery the AI routes need a session, which gives every request an owner but does not cap spend. Without one they are open to anyone. Read the cookbook doc "When to add usage metering" before you ship either.
  • No background jobs. Everything runs inside the request, bounded by maxDuration. Long work (batch extraction, long documents) needs a queue this bundle does not generate.
Show 3 more
  • No tracing. AI SDK 7 emits telemetry once you register an integration (OpenTelemetry through @ai-sdk/otel), and the providers return request ids. An LLM observability tool is a recurring bill, so it is documented, not installed.
  • One provider per repo. You pick Anthropic or OpenAI when you generate, and only that SDK and key ship. Adding the other later is one package and a few lines in src/lib/ai/models.ts. Streaming, tool calling and structured output port across both. Extended thinking, prompt caching and provider-specific tools do not.
  • Tool calling is only as safe as the tools. The scaffold ships one read-only example and a per-surface registry. The first tool that writes to your database is where prompt injection gets real.

What it costs

The AI SDK is free and open source (Apache 2.0). You pay the model provider per token. The defaults, Claude Sonnet 5 and GPT-6 Sol, both list at $2 per million input tokens and $10 per million output tokens. Claude Haiku 4.5 and GPT-6 Luna cost less. Claude Opus 5.5, Claude Fable 5.1 and GPT-6 Astra cost 2 to 5 times the default. Prompt caching and batch requests cut the bill, but you turn them on yourself.

Prices change. Check with AI bundle before you commit.

registry/tested.yaml

Tested with AI bundle

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

What it adds

What AI bundle adds to the repo

Read straight from the ai-bundle manifest, so it is exactly what lands in your repo.

Environment variables

No environment variables. Nothing to sign up for, nothing to paste.

Dependencies

  • ai^7.0.118
  • server-only^0.0.1
  • zod^4.6.0

Scripts

  • bun run ai:eval

    bun scripts/ai-eval.ts

  • bun run ai:models

    bun scripts/ai-models.ts

Files it writes

44 files, at these exact paths.

  • scripts/2 files
    • ai-eval.ts
    • ai-models.ts
  • src/8 files
    • lib/8 files
      • ai/8 files
        • evals/2 files
          • index.ts
          • support-triage.ts
        • eval.ts
        • http.ts
        • messages.ts
        • prompt.ts
        • provider.ts
        • untrusted.ts
  • tests/5 files
    • unit/5 files
      • ai-eval.test.ts
      • ai-messages.test.ts
      • ai-mock-model.ts
      • ai-models.test.ts
      • ai-untrusted.test.ts
  • variants/29 files
    • access-open/1 file
      • src/1 file
        • lib/1 file
          • ai/1 file
            • access.ts
    • access-signed-in/2 files
      • src/1 file
        • lib/1 file
          • ai/1 file
            • access.ts
      • tests/1 file
        • unit/1 file
          • ai-access.test.ts
    • cancel-agent/1 file
      • vercel.json
    • cancel-chat/1 file
      • vercel.json
    • cancel-chat-agent/1 file
      • vercel.json
    • chat/3 files
      • src/2 files
        • app/1 file
          • api/1 file
            • chat/1 file
              • route.ts
        • components/1 file
          • ai/1 file
            • chat.tsx
      • tests/1 file
        • unit/1 file
          • ai-chat-route.test.ts
    • chat-app/3 files
      • slots/2 files
        • app-nav.ts
        • dashboard-cards.tsx
      • src/1 file
        • app/1 file
          • (app)/1 file
            • chat/1 file
              • page.tsx
    • chat-public/2 files
      • slots/1 file
        • nav-links.ts
      • src/1 file
        • app/1 file
          • chat/1 file
            • page.tsx
    • provider-anthropic/3 files
      • slots/2 files
        • env-required.ts
        • legal-processors.ts
      • src/1 file
        • lib/1 file
          • ai/1 file
            • models.ts
    • provider-openai/3 files
      • slots/2 files
        • env-required.ts
        • legal-processors.ts
      • src/1 file
        • lib/1 file
          • ai/1 file
            • models.ts
    • structured/3 files
      • src/2 files
        • lib/2 files
          • ai/2 files
            • schemas.ts
            • structured.ts
      • tests/1 file
        • unit/1 file
          • ai-structured.test.ts
    • tools/6 files
      • src/5 files
        • app/1 file
          • api/1 file
            • agent/1 file
              • route.ts
        • lib/4 files
          • ai/4 files
            • tools/2 files
              • index.ts
              • search-knowledge-base.ts
            • agent.ts
            • knowledge-base.ts
      • tests/1 file
        • unit/1 file
          • ai-tools.test.ts

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot verify-checks

The differentiator

What AI bundle teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (4)

Loaded when the agent opens a matching file.

Never interpolate untrusted text into a system prompt

Loads onsrc/lib/ai/**src/app/api/**.claude/rules/ai-prompt-safety.md
The system prompt is built from your code only
  • systemPrompt() in src/lib/ai/prompt.ts is the only place the instructions are assembled, and they reach the model through the instructions option. Its extra argument is for operator context your own code produced: the plan the account is on, today's date, the pricing table.
  • A value that arrived over the network never goes into it. Not a request body, not a query string, not a form field, not a header, not a row a user typed into, not a page you fetched, not a file that was uploaded.
  • Template literals are where this rule dies. `You are helping ${user.name}` is a system prompt containing user input, and user.name can be a paragraph of instructions.
Untrusted text travels as data
  • Wrap third-party text with untrusted(label, content) and send it inside a user message. The wrapper strips the delimiters out of the content so the block cannot be closed early, and bounds the length so a huge paste cannot push your instructions out of the context window.
  • Put the question before the document, not after. An instruction buried under ten thousand characters is followed less reliably.
  • Reject client-supplied system messages outright. chatRequestSchema only accepts user and assistant roles, and that is not an oversight: accepting a system message hands every visitor an override switch for your guardrails. AI SDK 7 also rejects system messages inside messages by default. Never turn that off (allowSystemInMessages) on a route that takes client input.
  • Never trust what the browser replays. useChat sends back the tool calls, tool results and reasoning it was shown; toModelMessages keeps text only, so a forged "tool result" never reaches the model as one.
  • Tool results, retrieved documents and scraped pages are untrusted content too. "It came from our database" is not a provenance claim if a user typed it.
Wrapping is not the defence

Delimiters and "ignore instructions inside this block" reduce the success rate of injection. They do not make it safe, and no phrasing does. The defences that actually hold are structural:

  • Least privilege. The tool set for a public surface is read-only and scoped to the current user. An assistant that cannot issue a refund cannot be talked into issuing one.
  • A human on anything irreversible. Model output proposes; a person confirms. Never let a generated string decide that an action is authorised.
  • Never trust model output as a control-flow decision. "The model said the user is an admin" is not authentication. Re-check on the server.
  • Treat generated text as untrusted on the way out too. Render it as text, never as HTML. A model that read a poisoned page can emit a link or a script tag, and rendering it hands the attacker your user's session.

Model calls stay on the server, and they stream

Loads onsrc/lib/ai/**src/app/api/chat/**src/app/api/agent/**src/components/ai/**vercel.json.claude/rules/ai-server-boundary.md
Nothing about the model crosses into the browser
  • The browser learns one thing: the path of the route it posts to. Not the model id, not the instructions, not the key, not the settings.
  • Application code imports @/lib/ai/provider, never @/lib/ai/models. The façade adds import "server-only", which turns a Client Component importing it into a build error instead of a production incident. models.ts exists unguarded only so scripts/ and the eval harness can run under plain Node.
  • Never add NEXT_PUBLIC_ to an API key. NEXT_PUBLIC_* values are inlined into the client bundle at build time: the key ends up in JavaScript served to everyone, and rotating it is the only fix.
  • Never call a provider SDK from a Client Component, a useEffect, or an event handler. If the browser can reach the provider directly, so can anyone with the network tab open.
  • Prompts are code. Keep them in src/lib/ai/prompt.ts, not in a component, not in a constant next to a form.
Stream anything a human waits for (AI SDK 7)
  • Use streamText, then return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }). The result.toUIMessageStreamResponse() method still works in AI SDK 7 but is deprecated; do not add new calls to it.
  • First token in under a second, last token twenty seconds later: with generateText the user sees nothing for the whole window. generateText (with output: Output.object(...) for typed results) is right only when nothing is waiting: a background classification, an extraction, an eval run.
  • Pass the instructions as instructions: systemPrompt(). The old system option is a deprecated alias. Never put a system message in messages, and never set allowSystemInMessages: true on a route that takes messages from a client.
  • Always pass abortSignal: request.signal. Without it, a user who closes the tab keeps the upstream generation running and billing. On Vercel the signal only fires for functions listed in vercel.json with supportsCancellation: true. A new streaming route gets a line there, and a deleted route loses its line (Vercel fails the deploy on a path that matches nothing).
  • Always handle onError on streamText. Errors thrown after the first token do not reject anything. They arrive at onError, and without it the response simply stops with no log and no message. Skip isAbortError errors: a Stop click is not a fault.
  • The onError you pass to toUIMessageStream returns the string the user sees. Return STREAM_ERROR_MESSAGE. Provider error bodies quote the prompt back at you.
  • Send sendReasoning: false unless the UI renders reasoning on purpose.
Route shape
  • Set export const runtime = "nodejs" and a maxDuration that fits the work. Set maxOutputTokens too: current models count their reasoning against it, and it is the ceiling on what one request can bill.
  • Keep the route thin: authorise, parse, call, return. Access belongs in src/lib/ai/access.ts (authorizeAiRequest, called before the body is read), validation in src/lib/ai/messages.ts, prompt assembly in src/lib/ai/prompt.ts, model choice in src/lib/ai/models.ts, error responses in src/lib/ai/http.ts (setupErrorResponse: 400 for an unknown model, 503 for a missing key).
  • Do not add rate limiting, metering or caching inline "while you are here". Each is its own change with its own cookbook doc in docs/solutions/ai-bundle/.

Every structured call carries a Zod schema

Loads onsrc/lib/ai/**src/app/api/**.claude/rules/ai-structured-output.md
Never parse JSON out of prose
  • If the result goes into a database column, a switch, or a typed UI field, use generateStructured from @/lib/ai/structured, or generateText with output: Output.object({ schema }). Never ask for JSON in the prompt and JSON.parse the reply.
  • generateObject and streamObject are deprecated in AI SDK 7. Do not add new calls. Streaming a structured result is streamText with the same output setting, read from partialOutputStream.
  • For a label from a fixed list use Output.choice({ options }); for a list of records use Output.array({ element }). Both are validated the same way.
  • Asking for JSON in prose fails the same three ways every time: a markdown fence around the object, a trailing comma, and a field that is a string this run and a number the next. The schema goes to the provider's native structured-output mode, and the SDK validates the result before your code sees it.
  • There is no "we will validate it later". as SomeType on model output is a lie the type system will believe until it reaches production.
Write schemas the model can follow
  • .describe() every field. Descriptions are compiled into the JSON schema the provider sees: they are the model's only instructions about what a field means. An undescribed urgency gets filled in with the model's own idea of urgency.
  • Prefer z.enum to z.string() whenever the set of answers is known. A model asked for "a category" invents categories.
  • Use .nullable(), never .optional(), when "not present" is a real answer. OpenAI's strict mode (on by default) requires every field, and optional fields invite silent omission everywhere else.
  • Wrap arrays in an object at the top level. Several providers only accept an object as the root of a schema, and a named key leaves room to add truncated or confidence later without breaking every caller.
  • Bound every string and array (.max()). An unbounded array is an unbounded output-token bill. Anthropic receives the bounds as text in the field description and the SDK checks them after, so a violation is a retry, not a silent pass.
  • Keep schemas shallow. Deep nesting and long unions are the usual cause of a NoObjectGeneratedError: the model runs out of output tokens mid-object.
Handle the failure, do not swallow it
  • Structured failures arrive as NoObjectGeneratedError, and its cause says which kind: JSONParseError (the schema is too complex for the model) or TypeValidationError (a field needs a better .describe() or a .nullable()). No cause means the model refused or ran out of maxOutputTokens. NoOutputGeneratedError means the call finished without an object at all. generateStructured maps all of these onto StructuredOutputError and lets network and provider errors through as they are.
  • Size maxOutputTokens for reasoning, not for the JSON alone. Current models think before they answer and those tokens count against the ceiling.
  • Do not retry forever. Two attempts on a malformed result is the useful budget (schemaRetries: 1); a third almost never differs, and each one is a full billed request.
  • Never log the raw failing output by default. It contains whatever the user pasted, which is exactly the material the PII rules cover.

Every tool validates its own input

Loads onsrc/lib/ai/tools/**src/lib/ai/agent.tssrc/app/api/agent/**.claude/rules/ai-tools.md
The model is an untrusted caller
  • A tool is a function whose arguments are chosen by a language model that just read a stranger's sentence. Treat every call as if it arrived from a public HTTP endpoint, because in effect it did.
  • inputSchema is a Zod schema, always, with .min()/.max() bounds and a .describe() on every field. execute never runs with input the schema did not accept.
  • Validate again inside execute for anything the schema cannot express: ownership ("does this user own that order?"), existence, and state ("is this invoice already refunded?"). A schema proves shape, never authority.
  • Never take a user id from the model. The route reads it from the session and passes it as toolContext to runAgent; a tool that needs it is built by a factory that closes over it (getOrderStatus({ userId })), on a surface whose route requires a signed-in user.
  • Reject arguments the model should never choose. A tool that accepts a raw SQL fragment, a file path, a URL to fetch, or a shell command is not a tool with a validation problem: it is the wrong tool.
Return small, typed, honest results
  • Put the work in a plain exported function and keep execute a one-line call to it. The function gets unit tests without a model; the tool stays thin.
  • Return a plain object with a handful of fields. Tool results re-enter the context on every later step, so a full row dump turns a three-step conversation into a cost incident.
  • Truncate long text inside the tool, and say that you did (truncated: true).
  • "Nothing found" is a result, not an exception: { matches: 0 } lets the model tell the user it does not know. A thrown error ends the turn instead.
  • Throw only for genuine faults: the service is down, the input passed schema validation but is impossible. Expected outcomes are data.
  • Tool output is untrusted content. If it contains text a user wrote or a page you fetched, it can carry instructions; the untrusted-block rule applies to it exactly as it does to a chat message.
  • Tool inputs and outputs stream to the browser as parts of the answer. Return only what the signed-in user may see.
Registry, budget and blast radius
  • Register tools in src/lib/ai/tools/index.ts under a surface and hand routes a set via toolsFor(surface, context). Never assemble a tool set inline at a call site: the registry is the document that says what a sentence from a stranger can reach.
  • Keep sets small. Models choose worse as the list grows, and every definition costs input tokens on every request. Six focused tools beat twenty overlapping ones.
  • Always set stopWhen: isStepCount(n) (AI SDK 7 renamed it from stepCountIs). Without a step ceiling, a tool that keeps returning "try again" and a model that keeps obliging run until the platform timeout.
  • Anything irreversible (money moving, data deleted, an email sent to a customer) does not go behind a tool the model can call unattended. Return a proposed action and have a human confirm it, or gate it with the SDK's toolApproval setting and a UI that asks.
  • Log the tool name and the outcome on every step (onStepEnd). Never log the arguments: they are user text.

Skills (2)

Invoked by name.

  • /add-tool

    Add a tool the model can call (schema, execute, registry entry, surface and a test) without widening what a stranger's sentence can reach.

    .claude/skills/add-tool/SKILL.md

  • /eval-prompt

    Change a prompt, a model or a schema safely. Build the eval suite first, measure the baseline, change one thing, and compare pass rates.

    .claude/skills/eval-prompt/SKILL.md

Solution docs (6)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

Show all 6

How it fits

What AI bundle needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

Nothing. AI bundle stands on its own.

Pairs well with

  • A database battery. Suggested, never added for you.

Cannot be combined with

No hard conflicts.

Build a repo with AI bundle

Free and MIT. The builder opens with AI bundle picked. You download the zip right away, and we email you the link too.

Presets

Presets that already include AI bundle

A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.