Last reviewed: November 13, 2026 (Asia/Shanghai). References checked November 12 UTC.

Direct answer

Put the CometAPI request in a SvelteKit +page.server.ts form action. Let the browser submit one prompt to its own application, check the session’s generation permission and validate the prompt on the server, then call a server-only adapter. Return an accepted text reply as action data and display it with ordinary Svelte interpolation.

The SvelteKit form-actions guide documents this POST-to-action flow, validation failures with fail(), and the page’s form property. Start with a native form, then add use:enhance to update the page without a full reload. Keep generation out of load: an action triggers the page’s load functions again after it completes.

This tutorial targets SvelteKit 3 and Svelte 5. It uses the current explicit environment-variable system, not the older $env/static/private examples. The environment-variable guide documents src/env.ts and $app/env/private; the older environment modules are deprecated in SvelteKit 3.

Who this is for

Use this in an existing TypeScript SvelteKit application with a server-capable adapter and an established login/session system. The application must already decide which users may generate replies. This guide connects that decision to one single-turn text form; it does not create an identity system or a durable conversation store.

The example was type-checked and built in a private scratch project with SvelteKit 3.2.0, Svelte 5.57.3, Vite 8.3.8, adapter-node 6.0.2, and Node.js 24.22.1. Six offline tests passed using Vitest 5.1.0. No authenticated CometAPI call, browser automation, or deployment was performed. Account access and the complete browser flow remain staging checks below.

For a different server-rendered framework, the site’s Django form integration covers bound forms and framework CSRF tokens. Here, the implementation follows SvelteKit’s action-data and enhancement contracts.

Key takeaways

  • Declare the key and model as private runtime variables and import them only on the server.
  • Require the application’s trusted session permission before parsing or dispatching a generation request.
  • Accept exactly one nonblank prompt and construct the provider payload yourself.
  • Return fail() data for expected form failures; do not return raw provider errors.
  • Call update() when customizing use:enhance, so returned data reaches the page.
  • Treat the pending button as interface behavior; repeated HTTP submissions still need a durable policy.

Declare private configuration and session permission

In src/env.ts, import defineEnvVars from @sveltejs/kit/env and export its result as variables. Add two entries named COMETAPI_KEY and COMETAPI_MODEL to your existing definitions. For each entry, provide a schema validator that returns the supplied string or an empty string when it is missing: (value) => value ?? ''. Leave both entries private and dynamic, which are the defaults documented in the environment-variable guide.

This validator deliberately turns missing values into empty strings so the adapter can reject incomplete configuration before dispatch. Supply real values through your protected server environment; for local development, use an excluded local environment file. Keep credential values out of shared configuration examples. Follow the site’s credential-handling guide when preparing shared examples.

Choose an exact current model identifier available to your account for text Chat Completions. The browser will not supply the key, endpoint, model, message roles, or generation options. This example sends only the required text request and the non-streaming switch; verify any model-specific output settings before adding them.

Merge this property into the existing App.Locals interface in src/app.d.ts:

declare global {
  namespace App {
    interface Locals {
      chatAllowed?: boolean;
    }
  }
}
export {};

In your existing server authentication hook, populate event.locals.chatAllowed from verified session identity and the application’s generation entitlement. A missing or false value denies generation. Do not derive the flag from a submitted field or an unverified cookie. Apply your per-user spending admission before enabling the permission for a wider audience.

Add the server-only CometAPI adapter

Create src/lib/server/cometapi.ts with this function:

import { COMETAPI_KEY, COMETAPI_MODEL } from '$app/env/private';

export class ChatUnavailable extends Error {}

export async function completeText(prompt: string): Promise<string> {
  if (!COMETAPI_KEY.trim() || !COMETAPI_MODEL.trim()) {
    throw new ChatUnavailable('missing_configuration');
  }

  const headers = new Headers({
    'content-type': 'application/json',
    accept: 'application/json'
  });
  headers.set('authorization', ['Bearer', COMETAPI_KEY].join(' '));
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 20_000);
  let response: Response | undefined;
  try {
    response = await globalThis.fetch(
      'https://api.cometapi.com/v1/chat/completions',
      {
        method: 'POST',
        redirect: 'error',
        signal: controller.signal,
        headers,
        body: JSON.stringify({
          model: COMETAPI_MODEL,
          messages: [{ role: 'user', content: prompt }],
          stream: false
        })
      }
    );
    const media = (response.headers.get('content-type') ?? '')
      .split(';')[0].trim().toLowerCase();
    if (response.status !== 200 || media !== 'application/json') {
      throw new ChatUnavailable('unexpected_http_response');
    }
    if (!response.body) throw new ChatUnavailable('missing_body');

    const storage = new Uint8Array(131_072);
    const reader = response.body.getReader();
    let bytes = 0;
    try {
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        if (value.byteLength > storage.byteLength - bytes) {
          throw new ChatUnavailable('response_too_large');
        }
        storage.set(value, bytes);
        bytes += value.byteLength;
      }
      controller.signal.throwIfAborted();
    } catch (cause) {
      controller.abort();
      await reader.cancel().catch(() => {});
      throw cause;
    } finally {
      reader.releaseLock();
    }

    const data = JSON.parse(new TextDecoder('utf-8', { fatal: true })
      .decode(storage.subarray(0, bytes)));
    const choice = data?.choices?.[0];
    const message = choice?.message;
    if (data?.object !== 'chat.completion' ||
        !Array.isArray(data.choices) || data.choices.length !== 1 ||
        choice?.finish_reason !== 'stop' || message?.role !== 'assistant' ||
        message.refusal != null || message.tool_calls != null ||
        typeof message.content !== 'string' || !message.content.trim()) {
      throw new ChatUnavailable('unaccepted_completion');
    }
    return message.content;
  } catch {
    controller.abort();
    if (response?.body && !response.body.locked) {
      await response.body.cancel().catch(() => {});
    }
    throw new ChatUnavailable('completion_unavailable');
  } finally {
    clearTimeout(timer);
  }
}

The CometAPI chat reference documents the fixed endpoint, bearer authentication, model/messages payload, and completion fields. Accepting one finished assistant text choice is this application’s policy. Refusals, tool calls, truncation, empty text, and incompatible envelopes become a form failure even when transport succeeds.

The SvelteKit server-only modules reference documents private environment imports and server directories. Keep this module out of browser imports. That compile-time boundary helps prevent accidental bundling; it does not prevent a developer from explicitly returning a secret in action data.

The Node.js globals reference documents Fetch, abort controllers, decoding globals, and throwIfAborted(). The timer stays active through asynchronous body reading. Twenty seconds and 128 KiB are illustrative application settings, independent of provider token allowances. Incoming transport chunks, decoded strings, parsed objects, and concurrent requests add memory outside the fixed buffer. Synchronous decoding and parsing cannot be interrupted midway by this timer.

The reader rejects an oversized chunk before copying it, cancels rejected responses, and releases its reader lock. For the detailed byte-accounting rationale and additional fixtures, see response limits before JSON parsing . The adapter performs no automatic retry. Local cancellation does not establish whether upstream work completed or was charged.

Connect the form action and page

Create src/routes/chat/+page.server.ts:

import { fail } from '@sveltejs/kit';
import { completeText } from '../../lib/server/cometapi';
import type { Actions } from './$types';

export const prerender = false;

export const actions = {
  default: async ({ request, setHeaders, locals }) => {
    setHeaders({ 'cache-control': 'private, no-store' });
    if (locals.chatAllowed !== true) {
      return fail(403, { error: 'Generation is not allowed for this session.' });
    }
    const data = await request.formData();
    const values = data.getAll('prompt');
    const raw = values.length === 1 ? values[0] : null;
    if (typeof raw !== 'string' || raw.length > 2000 || !raw.trim()) {
      return fail(400, { error: 'Enter one prompt of 1–2,000 characters.' });
    }

    try {
      return { reply: await completeText(raw.trim()) };
    } catch {
      return fail(502, {
        error: 'A completed reply is unavailable. Do not resubmit automatically.'
      });
    }
  }
} satisfies Actions;

The relative import matches the file locations above. The server rejects a missing, repeated, file-valued, blank, or overlong prompt before generation. Additional fields cannot replace provider configuration because the action never forwards them. The character check uses JavaScript string length, measured in UTF-16 code units; it is neither a token count nor an incoming-body limit. Set a separate ingress body limit before request.formData() parses the complete submission.

Create src/routes/chat/+page.svelte:

<script lang="ts">
  import { enhance, type SubmitFunction } from '$app/forms';
  import type { PageProps } from './$types';

  let { form }: PageProps = $props();
  let pending = $state(false);
  const submit: SubmitFunction = ({ cancel }) => {
    if (pending) {
      cancel();
      return;
    }
    pending = true;
    return async ({ update }) => {
      try {
        await update({ reset: false });
      } finally {
        pending = false;
      }
    };
  };
</script>

<svelte:head><title>Ask one text question</title></svelte:head>

<h1>Ask one text question</h1>
<form method="POST" use:enhance={submit} aria-busy={pending}>
  <label for="prompt">Prompt</label>
  <textarea id="prompt" name="prompt" rows="6" maxlength="2000" required></textarea>
  <button type="submit" disabled={pending}>
    {pending ? 'Generating…' : 'Generate reply'}
  </button>
</form>

{#if form?.error}<p role="alert">{form.error}</p>{/if}
{#if form?.reply}
  <section aria-label="Reply">
    <h2>Reply</h2>
    <pre style="white-space: pre-wrap">{form.reply}</pre>
  </section>
{/if}

The native POST form also works without JavaScript. With JavaScript, the pending flag blocks a second enhanced submission in this component until the result is processed. The $app/forms reference documents the callback and its update() function. Returning a custom callback replaces default result handling, so calling update() is what applies the result. Here reset: false preserves the editable input after enhanced submission; it does not preserve input across a native full-page POST.

The Svelte markup reference documents escaping for interpolated expressions. {form.reply} displays HTML-looking model text as text. Do not change it to {@html ...} to add formatting. A separate Markdown renderer would need a separately reviewed rendering policy.

Contract details to verify

BoundaryApplication decisionEvidence to require
SessionOnly locals.chatAllowed === true may dispatchA denied session produces zero adapter calls.
InputExactly one nonblank prompt within 2,000 UTF-16 code unitsMissing, repeated, file-valued, and oversized values never dispatch.
ProviderServer-owned URL, key, model, and message roleSubmitted model or URL fields do not affect the outbound request.
ResultOne completed assistant text choiceRefusals, truncation, and malformed envelopes reach the fixed failure.
InterfaceNative POST plus optional enhancementBoth modes display results; enhanced mode returns from pending state.
ReplayNo durable operation identity in this exampleRefreshes, new tabs, and repeated POSTs are assessed before product rollout.

Retain framework origin protection. The current SvelteKit configuration reference documents csrf.trustedOrigins, its empty default, and the risk of trusting '*'. It also says these CSRF checks apply in production, not local development. In SvelteKit 3, configuration belongs in the sveltekit(...) plugin options in vite.config, and the old checkOrigin option has been removed. Do not copy a version-2 workaround to make a failing request pass.

Check the deployed application’s externally visible origin and proxy configuration. Test a foreign-origin form POST against a production build or staging deployment, and require rejection before adapter dispatch. A direct call to the action function cannot prove framework origin enforcement.

Happy-path operator workflow

Start without credentials. Replace the adapter in a focused action test, then submit one form value and count calls. For an existing Vitest project, save this as src/routes/chat/action.test.ts:

import { beforeEach, expect, it, vi } from 'vitest';

vi.mock('../../lib/server/cometapi', () => ({ completeText: vi.fn() }));
import { completeText } from '../../lib/server/cometapi';
import { actions } from './+page.server';

const generate = vi.mocked(completeText);
beforeEach(() => { generate.mockReset(); });

async function submit(values: string[], allowed = true) {
  const body = new FormData();
  for (const value of values) body.append('prompt', value);
  body.append('model', 'browser-choice');
  return actions.default({
    request: new Request('https://app.example/chat', {
      method: 'POST', body
    }),
    setHeaders: vi.fn(),
    locals: { chatAllowed: allowed }
  } as unknown as Parameters<typeof actions.default>[0]);
}

it('dispatches only trimmed prompt text once', async () => {
  generate.mockResolvedValue('Hello');
  expect(await submit([' Hello '])).toEqual({ reply: 'Hello' });
  expect(generate).toHaveBeenCalledExactlyOnceWith('Hello');
});

it('rejects invalid values before generation', async () => {
  for (const values of [[], [' '], ['x'.repeat(2001)], ['a', 'b']]) {
    expect(await submit(values)).toHaveProperty('status', 400);
  }
  expect(generate).not.toHaveBeenCalled();
});

Run your project’s type check, production build, and npx vitest run. The scratch verification also exercised a denied session and a rejected adapter promise; both produced fixed action failures without generation retries. Two additional adapter tests checked the fixed outbound payload and rejection of non-200 status, wrong media type, malformed JSON, oversize content, truncation, refusal, and a null envelope. These six test cases passed; they do not exercise browser rendering, framework middleware, or account capability.

After the offline checks, set the protected configuration and authorize one staging session. Submit a harmless prompt with JavaScript enabled, then repeat with JavaScript disabled. Require one accepted text reply per submission, no key in browser assets or responses, and the expected pending-state transition in enhanced mode. Inspect only status, time, configured model, and acceptance outcome in routine diagnostics; omit prompts, replies, credentials, and raw error bodies.

Error-path operator workflow

Test denied sessions and malformed field values with an adapter spy, requiring zero calls. Add a file-valued prompt fixture, since the text-only cases above do not cover it. Temporarily omit the server key and require a fixed failure with zero outbound HTTP requests. Stub a provider timeout and verify that the action returns its fixed error without an automatic retry.

For the page, return a synthetic reply such as <img src=x onerror=alert(1)> and require visible text with no injected element. Check that a failed enhanced request releases the pending state and shows an error. Repeat the foreign-origin rejection check through staging ingress using the production build; development behavior cannot establish this contract.

The action returns transient result data. A native POST refresh, another tab, or a manually repeated request can run generation again. Before using this for a paid multi-user product, add a durable user-bound operation identifier and a stored result that a separate GET can retrieve. If the connection ends after upstream execution, reconcile the original attempt before offering another generation.

Failure modes

The page always returns 403. Verify that the existing server authentication hook sets the permission for an entitled session. Keeping the flag unset is the intended default; bypassing it would open the spending boundary.

An old environment import or type import fails. Match the installed major version. This example uses SvelteKit 3’s $app/env/private and imports SubmitFunction from $app/forms. Restart development after changing environment definitions and run the generated-type sync used by your project.

The action succeeds but no reply appears. Confirm the form posts to this page’s action, the component reads form, and a custom enhancement callback calls update(). Do not put a separate JSON endpoint at the same path while diagnosing action routing.

Origin checks behave differently after deployment. Confirm production configuration and the application’s perceived public origin. Keep the trusted-origin list narrow and repair proxy configuration instead of allowing every origin.

Inference repeats after a successful submission. Ensure load performs no generation. The pending control only covers this enhanced component; persistent replay handling belongs to the server operation design.

FAQ

Can the API key go into a public environment variable?

Keep it private. The browser needs only the local form and returned display data. Public environment values are intended for client access.

Can I use this in an entirely static export?

The action needs a server to handle POST. Use a server-capable adapter and leave this route unprerendered. Static hosting alone cannot execute the adapter function.

Does use:enhance stream generated tokens?

This implementation waits for one non-streaming completion, then updates the page with the full accepted text. Progressive enhancement changes form interaction; a token stream would need a different response and client contract.

Is a disabled button an idempotency mechanism?

It limits repeated interaction in this component while JavaScript is running. It does not cover other tabs, native form replays, or direct requests. Use durable server identity when repeated generation must be prevented.

Reader next step

Add the private environment definitions, connect your existing session permission, and install the action and page in a development branch. Pass the offline checks before making one authorized staging request, then verify native submission, enhanced errors, escaping, and production origin rejection through the real ingress.

If account setup is still needed for that final request, Start with CometAPI , configure the key on the server, and select a verified text model before returning to the same acceptance procedure.

Sources checked

Eight primary public references returned HTTP 200 on November 12, 2026 UTC:

These maintained technical references support an evergreen integration task. They were checked against the current SvelteKit 3 documentation rather than treated as a new-release announcement. Input and buffer limits, permission wiring, completion acceptance, and the test sequence are application recommendations.