Skip to content

Astro Integration

Astro is the recommended framework for building sites with Aether’s visual headless CMS. This guide covers everything from setup to deployment.

  • Build-Time Content Fetching - Content fetched during build for maximum performance
  • Auto-Middleware Injection - SDK automatically configures CSP headers
  • Type-Safe Development - Full TypeScript autocomplete for all content fields
  • Islands Architecture - SDK only loads when needed (in editor mode)
  • Framework Agnostic - Use React, Vue, Svelte, or vanilla JS for interactive parts
  • Static Site Generation - Fast, SEO-friendly sites with optional SSR
Terminal window
npm create astro@latest my-aether-site
cd my-aether-site
Terminal window
npm install @aether-official/sites-sdk
npm install -D @aether-official/cli
Terminal window
npx aether-sdk init

This will:

  • Prompt for your credentials (Site ID, Org ID, API Token)
  • Test the connection
  • Create .env file
  • Fetch your schema
  • Generate TypeScript types in src/aether-types.d.ts
astro.config.mjs
import { defineConfig } from 'astro/config';
import aetherSites from '@aether-official/sites-sdk/astro';
export default defineConfig({
integrations: [
aetherSites({
siteId: process.env.AETHER_SITE_ID,
organizationId: process.env.AETHER_ORG_ID,
editorOrigin: process.env.PUBLIC_AETHER_EDITOR_ORIGIN || 'http://localhost:3000',
enabled: true,
})
]
});
src/pages/index.astro
---
import { fetch, aether } from '@aether-official/sites-sdk/astro';
import { SECTIONS } from '../aether-types'; // Auto-generated!
import { getConfig } from '@aether-official/sites-sdk/astro';
// SECURITY: Use process.env (server-only) to keep API token secure
// Never use import.meta.env as it can expose secrets to the browser
const config = getConfig(process.env as Record<string, string | undefined>);
// Type-safe with full autocomplete!
const hero = await fetch.section(SECTIONS.HERO, config);
const helper = aether.section(SECTIONS.HERO);
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{hero.data.title}</title>
</head>
<body>
<section {...helper.section()}>
<h1 {...helper.field('title')}>
{hero.data.title}
</h1>
<p {...helper.field('subtitle')}>
{hero.data.subtitle}
</p>
</section>
</body>
</html>

That’s it! The Astro integration automatically:

  • Injects middleware for CSP headers
  • Initializes the SDK for visual editing
  • No manual script tags needed!

CRITICAL: Never expose your AETHER_API_TOKEN to the browser!

---
// ✅ CORRECT: Use process.env (server-side only)
const config = getConfig(process.env as Record<string, string | undefined>);
// ❌ WRONG: Never use import.meta.env for API tokens
// const config = getConfig(import.meta.env); // This can expose tokens to browser!
---

Why this matters:

  • process.env is only available server-side during SSR/SSG builds
  • import.meta.env variables (especially PUBLIC_*) are bundled into browser code
  • API tokens should never be accessible from the browser
  • Vercel and other platforms expose process.env during build time

Environment Variables:

Terminal window
# Vercel/Netlify Dashboard - Server-side only (build-time)
AETHER_API_TOKEN=your_token_here
AETHER_API_URL=https://your-platform.com
# Public variables (safe for browser)
PUBLIC_AETHER_SITE_ID=your_site_id
PUBLIC_AETHER_ORGANIZATION_ID=your_org_id
PUBLIC_AETHER_EDITOR_ORIGIN=https://your-platform.com
---
import { fetch, aether } from '@aether-official/sites-sdk/astro';
import { SECTIONS } from '../aether-types'; // Auto-generated types!
import { getConfig } from '@aether-official/sites-sdk/astro';
// SECURITY: Use process.env to keep API tokens server-side only
const config = getConfig(process.env as Record<string, string | undefined>);
// Full autocomplete for section ID and all fields!
const hero = await fetch.section(SECTIONS.HERO, config);
const helper = aether.section(SECTIONS.HERO);
---
<section {...helper.section()}>
<h1 {...helper.field('title')}>
{hero.data.title}
</h1>
</section>
---
import { fetch, aether } from '@aether-official/sites-sdk/astro';
import { SECTIONS } from '../aether-types';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
// Fetch multiple sections in parallel for better performance
const [hero, features, cta] = await Promise.all([
fetch.section(SECTIONS.HERO, config),
fetch.section(SECTIONS.FEATURES, config),
fetch.section(SECTIONS.CTA, config),
]);
const heroHelper = aether.section(SECTIONS.HERO);
const featuresHelper = aether.section(SECTIONS.FEATURES);
---
<section {...heroHelper.section()}>
<h1 {...heroHelper.field('title')}>
{hero.data.title}
</h1>
</section>
<section {...featuresHelper.section()}>
<h2 {...featuresHelper.field('heading')}>
{features.data.heading}
</h2>
</section>

When you add or modify sections in the CMS:

Terminal window
# Sync latest schema and regenerate types
npx aether-sdk sync && npx aether-sdk types
# Restart dev server
npm run dev

Your IDE instantly knows about:

  • New sections you created
  • New fields you added
  • Changed field types
src/layouts/BaseLayout.astro
---
interface Props {
title?: string;
}
const { title = 'My Site' } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
</head>
<body>
<slot />
<!-- No SDK initialization needed! -->
<!-- The Astro integration handles it automatically -->
</body>
</html>
src/components/HeroSection.astro
---
import { fetch, aether } from '@aether-official/sites-sdk/astro';
import { SECTIONS } from '../aether-types';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(import.meta.env);
const hero = await fetch.section(SECTIONS.HERO, config);
const helper = aether.section(SECTIONS.HERO);
---
<section class="hero" {...helper.section()}>
<h1 {...helper.field('title')}>
{hero.data.title}
</h1>
<p {...helper.field('subtitle')}>
{hero.data.subtitle}
</p>
</section>
<style>
.hero {
padding: 4rem 2rem;
text-align: center;
}
</style>
---
import { fetch, aether } from '@aether-official/sites-sdk/astro';
import { SECTIONS } from '../aether-types';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(import.meta.env);
const team = await fetch.section(SECTIONS.TEAM, config);
const helper = aether.section(SECTIONS.TEAM);
---
<section {...helper.section()}>
<h2 {...helper.field('heading')}>
{team.data.heading}
</h2>
<div class="team-grid" {...helper.repeater('members', team.data.members)}>
{team.data.members?.map((member, i) => (
<div class="team-member" {...helper.repeaterItem(i)}>
<h3 {...helper.field(`members.${i}.name`)}>
{member.name}
</h3>
<p {...helper.field(`members.${i}.role`)}>
{member.role}
</p>
</div>
))}
</div>
</section>
<style>
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
</style>

Global settings allow you to manage site-wide information like contact details, business hours, social media links, SEO defaults, and analytics configuration. These settings are managed in the visual editor and can be accessed in any Astro component.

  • Contact Information: Phone, email, address, toll-free, fax
  • Business Hours: Timezone, weekly schedule, special hours
  • Social Media: Facebook, Twitter, LinkedIn, Instagram, YouTube, TikTok, Pinterest, GitHub
  • SEO Defaults: Default title, description, keywords, OG image
  • Analytics: Google Analytics, Tag Manager, Facebook Pixel, Hotjar, Plausible
---
import { getGlobalSettings } from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
// SECURITY: Use process.env to keep API token secure
const config = getConfig(process.env as Record<string, string | undefined>);
// Fetch global settings for the site
const globalSettings = await getGlobalSettings(
config.apiToken,
config.editorOrigin
);
---
{globalSettings && (
<footer>
<p>Contact: {globalSettings.contact?.email}</p>
<p>{globalSettings.contact?.phone}</p>
</footer>
)}

Display contact information in your site footer or contact page:

---
import { getGlobalSettings, formatAddress } from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
const contact = settings?.contact;
---
<footer class="site-footer">
<div class="contact-info">
<h3>Contact Us</h3>
{contact?.email && (
<p>
<strong>Email:</strong>
<a href={`mailto:${contact.email}`}>{contact.email}</a>
</p>
)}
{contact?.phone && (
<p>
<strong>Phone:</strong>
<a href={`tel:${contact.phone}`}>{contact.phone}</a>
</p>
)}
{contact?.tollFree && (
<p>
<strong>Toll Free:</strong>
<a href={`tel:${contact.tollFree}`}>{contact.tollFree}</a>
</p>
)}
{contact?.address && (
<p>
<strong>Address:</strong><br />
{formatAddress(contact.address)}
</p>
)}
</div>
</footer>
<style>
.site-footer {
background: #f5f5f5;
padding: 2rem;
margin-top: 4rem;
}
.contact-info a {
color: #0066cc;
text-decoration: none;
}
</style>

Create a dynamic business hours widget that shows current status:

---
import {
getGlobalSettings,
formatBusinessHours,
getCurrentStatus,
getNextOpening
} from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
const businessHours = settings?.businessHours;
const isOpen = businessHours ? getCurrentStatus(businessHours) === 'open' : false;
const nextOpening = businessHours && !isOpen ? getNextOpening(businessHours) : null;
---
{businessHours && (
<div class="business-hours">
<div class={`status ${isOpen ? 'open' : 'closed'}`}>
{isOpen ? '🟢 Open Now' : '🔴 Closed'}
</div>
{!isOpen && nextOpening && (
<p class="next-opening">{nextOpening}</p>
)}
<h4>Hours of Operation</h4>
<ul class="schedule">
{(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] as const).map(day => (
<li>
<span class="day">{day.charAt(0).toUpperCase() + day.slice(1)}:</span>
<span class="hours">
{formatBusinessHours(businessHours.schedule, day)}
</span>
</li>
))}
</ul>
{businessHours.specialHours && businessHours.specialHours.length > 0 && (
<div class="special-hours">
<h4>Special Hours</h4>
<ul>
{businessHours.specialHours.map(special => (
<li>
<strong>{special.date}:</strong> {special.note}
{special.open && special.close && ` (${special.open} - ${special.close})`}
</li>
))}
</ul>
</div>
)}
</div>
)}
<style>
.business-hours {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.status {
font-weight: bold;
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.status.open { color: #22c55e; }
.status.closed { color: #ef4444; }
.next-opening {
color: #64748b;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.schedule {
list-style: none;
padding: 0;
}
.schedule li {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #e5e7eb;
}
.day {
font-weight: 500;
}
.hours {
color: #64748b;
}
</style>

Display social media icons with links from global settings:

---
import { getGlobalSettings } from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
const social = settings?.socialMedia;
// Social media platforms with icons (using emoji or your icon library)
const platforms = [
{ key: 'facebook', label: 'Facebook', icon: '📘' },
{ key: 'twitter', label: 'Twitter/X', icon: '🐦' },
{ key: 'linkedin', label: 'LinkedIn', icon: '💼' },
{ key: 'instagram', label: 'Instagram', icon: '📸' },
{ key: 'youtube', label: 'YouTube', icon: '🎥' },
{ key: 'tiktok', label: 'TikTok', icon: '🎵' },
{ key: 'pinterest', label: 'Pinterest', icon: '📌' },
{ key: 'github', label: 'GitHub', icon: '⚙️' },
] as const;
---
{social && (
<div class="social-links">
<h3>Follow Us</h3>
<div class="social-icons">
{platforms.map(platform => {
const url = social[platform.key];
return url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={platform.label}
class="social-icon"
>
<span class="icon">{platform.icon}</span>
<span class="label">{platform.label}</span>
</a>
);
})}
{social.custom?.map(custom => (
<a
href={custom.url}
target="_blank"
rel="noopener noreferrer"
class="social-icon"
>
{custom.icon && <span class="icon">{custom.icon}</span>}
<span class="label">{custom.name}</span>
</a>
))}
</div>
</div>
)}
<style>
.social-links {
margin: 2rem 0;
}
.social-icons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.social-icon {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f5f5f5;
border-radius: 8px;
text-decoration: none;
color: #333;
transition: background 0.2s;
}
.social-icon:hover {
background: #e5e5e5;
}
.icon {
font-size: 1.5rem;
}
.label {
font-size: 0.875rem;
}
</style>

Use global SEO settings as defaults for your pages:

src/layouts/BaseLayout.astro
---
import { getGlobalSettings } from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
interface Props {
title?: string;
description?: string;
keywords?: string[];
ogImage?: string;
}
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
const seo = settings?.seo;
// Merge page-specific props with global defaults
const {
title = seo?.defaultTitle || 'My Site',
description = seo?.defaultDescription || '',
keywords = seo?.defaultKeywords || [],
ogImage = seo?.ogImage
} = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO Meta Tags -->
<title>{title}</title>
{description && <meta name="description" content={description} />}
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
<!-- Open Graph -->
<meta property="og:title" content={title} />
{description && <meta property="og:description" content={description} />}
{ogImage && <meta property="og:image" content={ogImage} />}
<!-- Twitter Card -->
{seo?.twitterCard && <meta name="twitter:card" content={seo.twitterCard} />}
<meta name="twitter:title" content={title} />
{description && <meta name="twitter:description" content={description} />}
<!-- Canonical URL -->
{seo?.canonicalUrl && <link rel="canonical" href={seo.canonicalUrl} />}
</head>
<body>
<slot />
</body>
</html>

Inject analytics scripts from global settings:

src/components/Analytics.astro
---
import { getGlobalSettings } from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
const analytics = settings?.analytics;
---
{analytics?.googleAnalyticsId && (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${analytics.googleAnalyticsId}`}></script>
<script is:inline define:vars={{ gaId: analytics.googleAnalyticsId }}>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', gaId);
</script>
</>
)}
{analytics?.googleTagManagerId && (
<>
<script is:inline define:vars={{ gtmId: analytics.googleTagManagerId }}>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer',gtmId);
</script>
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${analytics.googleTagManagerId}`}
height="0"
width="0"
style="display:none;visibility:hidden"
></iframe>
</noscript>
</>
)}
{analytics?.facebookPixelId && (
<script is:inline define:vars={{ pixelId: analytics.facebookPixelId }}>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', pixelId);
fbq('track', 'PageView');
</script>
)}
{analytics?.plausibleDomain && (
<script
defer
data-domain={analytics.plausibleDomain}
src="https://plausible.io/js/script.js"
></script>
)}
{analytics?.custom?.map(custom => (
custom.scriptUrl && (
<script
async
src={custom.scriptUrl}
data-tracking-id={custom.trackingId}
></script>
)
))}

Combine contact, business hours, and social media in a comprehensive footer:

src/components/SiteFooter.astro
---
import {
getGlobalSettings,
formatAddress,
formatBusinessHours,
getCurrentStatus
} from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
const isOpen = settings?.businessHours
? getCurrentStatus(settings.businessHours) === 'open'
: false;
---
<footer class="site-footer">
<div class="footer-grid">
<!-- Contact Section -->
{settings?.contact && (
<div class="footer-section">
<h3>Contact</h3>
{settings.contact.email && (
<p><a href={`mailto:${settings.contact.email}`}>{settings.contact.email}</a></p>
)}
{settings.contact.phone && (
<p><a href={`tel:${settings.contact.phone}`}>{settings.contact.phone}</a></p>
)}
{settings.contact.address && (
<address>{formatAddress(settings.contact.address)}</address>
)}
</div>
)}
<!-- Business Hours Section -->
{settings?.businessHours && (
<div class="footer-section">
<h3>Hours</h3>
<p class={`status ${isOpen ? 'open' : 'closed'}`}>
{isOpen ? 'Open Now' : 'Closed'}
</p>
<p>
Today: {formatBusinessHours(
settings.businessHours.schedule,
new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase() as any
)}
</p>
</div>
)}
<!-- Social Media Section -->
{settings?.socialMedia && (
<div class="footer-section">
<h3>Follow Us</h3>
<div class="social-links">
{settings.socialMedia.facebook && (
<a href={settings.socialMedia.facebook} target="_blank">Facebook</a>
)}
{settings.socialMedia.twitter && (
<a href={settings.socialMedia.twitter} target="_blank">Twitter</a>
)}
{settings.socialMedia.linkedin && (
<a href={settings.socialMedia.linkedin} target="_blank">LinkedIn</a>
)}
{settings.socialMedia.instagram && (
<a href={settings.socialMedia.instagram} target="_blank">Instagram</a>
)}
</div>
</div>
)}
</div>
<div class="footer-bottom">
<p>&copy; {new Date().getFullYear()} All rights reserved.</p>
</div>
</footer>
<style>
.site-footer {
background: #1f2937;
color: #f9fafb;
padding: 3rem 2rem 1rem;
margin-top: 4rem;
}
.footer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto 2rem;
}
.footer-section h3 {
color: #fff;
margin-bottom: 1rem;
}
.footer-section a {
color: #93c5fd;
text-decoration: none;
}
.footer-section a:hover {
color: #60a5fa;
}
.status.open { color: #34d399; }
.status.closed { color: #f87171; }
.social-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.footer-bottom {
text-align: center;
padding-top: 2rem;
border-top: 1px solid #374151;
color: #9ca3af;
}
</style>

Global settings are fully typed. Import types for custom components:

src/components/ContactWidget.astro
import type { ContactSettings, GlobalSettings } from '@aether-official/sites-sdk/global-settings';
interface Props {
contact: ContactSettings;
}
const { contact } = Astro.props;
// TypeScript knows all available fields: phone, email, address, etc.

Always check if global settings exist before using them:

---
import { getGlobalSettings } from '@aether-official/sites-sdk/global-settings';
import { getConfig } from '@aether-official/sites-sdk/astro';
const config = getConfig(process.env as Record<string, string | undefined>);
const settings = await getGlobalSettings(config.apiToken, config.editorOrigin);
// Safe access with optional chaining
const email = settings?.contact?.email;
const phone = settings?.contact?.phone;
---
{settings ? (
<footer>
{email && <a href={`mailto:${email}`}>{email}</a>}
{phone && <a href={`tel:${phone}`}>{phone}</a>}
</footer>
) : (
<footer>
<p>Contact information will be displayed here.</p>
</footer>
)}
src/pages/blog/[slug].astro
---
import { getAetherPage, getConfig } from '@aether-official/sites-sdk/astro';
export async function getStaticPaths() {
const config = getConfig(import.meta.env);
// Fetch all blog posts from Aether
// This would typically use a custom endpoint
const posts = await fetch(`${config.apiUrl}/blog/posts`).then(r => r.json());
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const config = getConfig(import.meta.env);
const content = await getAetherSection(post.sectionId, config);
---
<article data-aether-section={content.id}>
<h1 data-aether-field="title">{content.data.title}</h1>
<div data-aether-field="body">
{content.data.body}
</div>
</article>
astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static', // or 'hybrid' for SSR
build: {
inlineStylesheets: 'auto',
},
vite: {
optimizeDeps: {
exclude: ['@aether-official/sites-sdk'], // Let Astro handle bundling
},
},
});
vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "astro",
"env": {
"AETHER_API_URL": "@aether-api-url",
"AETHER_API_TOKEN": "@aether-api-token"
}
}

Environment variables in Vercel dashboard:

  • PUBLIC_AETHER_SITE_ID
  • PUBLIC_AETHER_ORG_ID
  • PUBLIC_AETHER_EDITOR_URL
  • AETHER_API_URL
  • AETHER_API_TOKEN
netlify.toml
[build]
command = "npm run build"
publish = "dist"
[[plugins]]
package = "@netlify/plugin-sitemap"
.github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run build
env:
AETHER_API_URL: ${{ secrets.AETHER_API_URL }}
AETHER_API_TOKEN: ${{ secrets.AETHER_API_TOKEN }}
- uses: actions/upload-pages-artifact@v2
with:
path: ./dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v2
id: deployment
Terminal window
# Start dev server
npm run dev
# Test in visual editor
# 1. Run dev server (localhost:4321)
# 2. Open Aether app
# 3. Navigate to Sites → Your Site → Open Editor
# 4. Editor loads http://localhost:4321?aether-editor=true
Terminal window
# Build and preview
npm run build
npm run preview

Problem: SDK doesn’t initialize in editor mode

Solution:

  1. Check URL has ?aether-editor=true
  2. Verify SDK script is in <script> tag (not in frontmatter)
  3. Look for console errors
  4. Ensure PUBLIC_ env vars are set

Problem: getAetherSection() fails

Solution:

  1. Check AETHER_API_URL and AETHER_API_TOKEN env vars
  2. Verify section ID exists in database
  3. Check network tab for API errors
  4. Use try/catch for better error messages

Problem: Build fails with SDK import errors

Solution:

astro.config.mjs
export default defineConfig({
vite: {
ssr: {
noExternal: ['@aether-official/sites-sdk'],
},
},
});