Skip to main content
ACI works with your existing frontend project. Every supported framework connects through the same frontend contract, .aci.yaml, component and layout contracts, and a renderer entrypoint, so the concepts you learn for one carry over to the rest. The contract is shared; each framework simply decides how to render the validated page payload. This page walks through setting up each one, from installing the SDK to wiring your first component.

Shared Setup

Install the SDK and TypeScript tooling:
npm install @gradial/aci zod@^4
npm install -D typescript tsx
Use this package name in all framework integrations:
import { defineComponentContract, defineLayoutContract, slot } from '@gradial/aci';

Contract File Rules

Keep contract files separate from runtime components:
src/cms/contracts/components/   Component names, schemas, render modes
src/cms/contracts/layouts/      Layout names and slot contracts
src/cms/renderer.ts             Renderer capsule entrypoint
src/components/                 Runtime framework components
Contract files may import @gradial/aci and zod. They should not import framework components, CSS, browser APIs, or runtime rendering code.

Astro

Astro is ACI’s recommended starting point. Its island architecture and static-first approach align naturally with ACI’s pre-compile model, so it’s the fastest way to get a feel for how content and code come together.

Config

version: "1"
siteId: "your_site_id"
framework: astro

source:
  root: "./"

componentRegistry: ./src/cms/contracts/components/index.ts
layoutRegistry: ./src/cms/contracts/layouts/index.ts
rendererEntry: ./src/cms/renderer.ts

capabilities:
  staticRender: true
  ssr: true
  ssrIslands: true
  clientIslands: true
  fragmentRender: true

routes:
  cmsManaged: "/[...slug]"
  frameworkOwned:
    - "/api/*"
    - "/_astro/*"

rendererProtocol: stdio-json

Component Contract

// src/cms/contracts/components/homeHero.contract.ts
import { defineComponentContract } from '@gradial/aci';
import { z } from 'zod';

export const homeHeroContract = defineComponentContract({
  name: 'home_hero',
  schema: z.object({
    eyebrow: z.string().optional(),
    headline: z.string().min(1),
    description: z.string().optional(),
    ctaLabel: z.string().min(1),
    ctaHref: z.string().min(1)
  }),
  renderModes: { canStatic: true, canSSR: true, canClientIsland: false }
});

Layout Contract

// src/cms/contracts/layouts/index.ts
import { defineLayoutContract, slot } from '@gradial/aci';

export default [
  defineLayoutContract({
    name: 'marketing',
    slots: [slot('main', true), slot('footer')]
  })
];

Renderer Entry

// src/cms/renderer.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import RenderPage from '../render/RenderPage.astro';
import type { GradialRenderer } from '@gradial/aci';

const renderer: GradialRenderer = {
  async renderPage(request) {
    const container = await AstroContainer.create();
    const html = await container.renderToString(RenderPage, {
      props: {
        input: {
          route: request.requestContext?.url || '/',
          page: request.page
        }
      }
    });

    return {
      html,
      status: 200,
      cachePolicy: { scope: 'public', ttl: 60 }
    };
  }
};

export default renderer;

Local Scripts

{
  "scripts": {
    "dev": "astro dev --host 0.0.0.0",
    "build": "ACI_CONTENT_ROOT=./.aci/compiled astro build",
    "preview": "ACI_CONTENT_ROOT=./.aci/compiled astro preview --host 0.0.0.0",
    "aci:compile": "aci build --compile-only",
    "content:compile": "aci build --skip-code --content ./.content --out ./.aci/compiled",
    "aci:build": "aci build --content ./.content",
    "aci:doctor": "aci doctor",
    "aci:validate": "npm run content:compile"
  }
}

Next.js

Next.js plugs into the same contract, centered on the App Router and SSR path. If you already run Next.js, ACI slots in alongside it.

Config

version: "1"
siteId: "your_site_id"
framework: next

source:
  root: "./"

componentRegistry: ./src/cms/contracts/components/index.ts
layoutRegistry: ./src/cms/contracts/layouts/index.ts
rendererEntry: ./src/cms/renderer.ts

capabilities:
  staticRender: true
  ssr: true
  ssrIslands: true
  clientIslands: true
  fragmentRender: true

routes:
  cmsManaged: "/[...slug]"
  frameworkOwned:
    - "/api/*"
    - "/_next/*"

rendererProtocol: stdio-json

CMS Catch-All Route

Create a catch-all App Router page and force dynamic SSR:
// src/app/[[...slug]]/page.tsx
import { PageNotFoundError, routeFromNextParams } from '@gradial/aci/next/server';
import { loadRenderInputOrNotFound } from '@/lib/content';
import { RenderPage } from '@/render/RenderPage';

export const dynamic = 'force-dynamic';

export default async function Page({
  params
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const input = await loadRenderInputOrNotFound(await routeFromNextParams(params));
  if (!input) throw new PageNotFoundError('/');
  return <RenderPage input={input} />;
}

Middleware

// src/middleware.ts
import { createGradialMiddleware } from '@gradial/aci/next/middleware';

export default createGradialMiddleware({
  siteId: process.env.ACI_SITE_ID || '',
  edgeConfig: process.env.EDGE_CONFIG,
  previewSignKey: process.env.ACI_PREVIEW_SIGN_KEY
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|\\.gradial-dam|api/aci-assets|gradial/assets).*)']
};

Release Asset Route

// src/app/gradial/assets/[releaseId]/[...path]/route.ts
export { GET } from '@gradial/aci/next/asset-route';
Run aci doctor to verify the catch-all route, dynamic SSR settings, package dependency, and asset route.

SvelteKit

SvelteKit uses the same .aci.yaml contract and a renderer function that returns HTML.

Config

version: "1"
siteId: "your_site_id"
framework: sveltekit

source:
  root: "./"
  outDir: "build"
  publicDir: "static"

componentRegistry: ./src/cms/contracts/components/index.ts
layoutRegistry: ./src/cms/contracts/layouts/index.ts
rendererEntry: ./src/cms/renderer.ts

capabilities:
  staticRender: true
  ssr: true
  ssrIslands: true
  clientIslands: true
  fragmentRender: true

routes:
  cmsManaged: "/[...slug]"
  frameworkOwned:
    - "/api/*"
    - "/_app/*"

rendererProtocol: stdio-json

Renderer Function

// src/render/renderPage.ts
import { render } from 'svelte/server';
import RenderPage from './RenderPage.svelte';
import type { RenderInput, RenderOutput } from '@gradial/aci/content';

export async function renderPage(input: RenderInput): Promise<RenderOutput> {
  const result = render(RenderPage, { props: { input } });
  return {
    route: input.route || '/',
    html: [
      '<!doctype html>',
      '<html lang="en">',
      '<head>',
      '<meta charset="utf-8" />',
      result.head,
      '</head>',
      '<body>',
      result.body,
      '</body>',
      '</html>'
    ].join(''),
    assets: { css: [], js: [], images: [] },
    diagnostics: []
  };
}

Local Content Structure

Content lives in .content/ as JSON files:
.content/
├── config/
│   └── site.json
└── pages/
    ├── home/
    │   └── _index.json
    └── product/
        └── aeroflow-pro/
            └── _index.json
Page documents use component names from the contract registry:
{
  "$type": "page",
  "id": "home",
  "status": "published",
  "layout": "marketing",
  "renderMode": "static",
  "metadata": {
    "title": "Welcome"
  },
  "regions": {
    "main": [
      {
        "id": "hero",
        "component": "home_hero",
        "props": {
          "headline": "Build faster with ACI"
        }
      }
    ]
  }
}

Validation Loop

Run this before committing starter changes:
aci doctor
aci build --compile-only
aci build --skip-code --content ./.content --out ./.aci/compiled
npm run typecheck
npm run build

Next: CLI Reference →