| title | Server Islands for Analytics Panels | |||||||
|---|---|---|---|---|---|---|---|---|
| impact | MEDIUM | |||||||
| impactDescription | Without server:defer, heavy analytics panels block the initial page render, causing visible layout shift and slow Time to First Byte on the NextDNS dashboard | |||||||
| type | capability | |||||||
| tags |
|
Use Astro 6 Server Islands (server:defer) to lazy-load NextDNS analytics panels without blocking the initial page render
Astro 5 introduced Server Islands — a pattern where individual components can be deferred to load after the initial page HTML is sent to the browser. The pattern carries over unchanged in Astro 6 (current major as of March 2026), which adds Live Content Collections, a built-in Fonts API, and built-in CSP configuration — none of which change the Server Islands API below. This is ideal for a NextDNS dashboard where:
- The profile list (fast, needed immediately) renders at page load
- The analytics panels (slow, can take 1–3 seconds) load after without blocking
The server:defer directive tells Astro to render a placeholder immediately and fetch the component
HTML asynchronously from the server. The component still runs on the server (the API key is never
exposed), but its rendering is decoupled from the initial response.
---
// ✅ src/components/AnalyticsPanel.astro — runs on server, deferred
import { nextdnsFetch } from '../lib/nextdns';
interface Props {
profileId: string;
from?: string;
}
const { profileId, from = '-7d' } = Astro.props;
let statusData: Array<{ status: string; queries: number }> = [];
let error: string | null = null;
try {
const res = await nextdnsFetch<{ data: typeof statusData }>(
`/profiles/${profileId}/analytics/status?from=${from}`
);
statusData = res.data;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load analytics';
}
---
{error ? (
<p class="text-red-500" role="alert">{error}</p>
) : (
<ul>
{statusData.map((item) => (
<li>
<span>{item.status}</span>
<span>{item.queries.toLocaleString()} queries</span>
</li>
))}
</ul>
)}---
// ✅ src/pages/profiles/[id]/index.astro — profile list loads fast, analytics deferred
import { nextdnsFetch } from '../../../lib/nextdns';
import AnalyticsPanel from '../../../components/AnalyticsPanel.astro';
import TopDomainsPanel from '../../../components/TopDomainsPanel.astro';
const { id } = Astro.params;
// Fast fetch — needed for initial render
const { data: profile } = await nextdnsFetch<{ data: { id: string; name: string } }>(
`/profiles/${id}`
);
---
<html>
<head><title>{profile.name} — Dashboard</title></head>
<body>
<!-- Renders immediately (fast) -->
<h1>{profile.name}</h1>
<p>Profile ID: {id}</p>
<!-- Deferred: rendered after initial HTML is sent -->
<section>
<h2>Query Status</h2>
<AnalyticsPanel server:defer profileId={id} from="-7d">
<!-- Fallback slot: shown while the island loads -->
<div slot="fallback" aria-live="polite" role="status">
Loading analytics…
</div>
</AnalyticsPanel>
</section>
<section>
<h2>Top Blocked Domains</h2>
<TopDomainsPanel server:defer profileId={id} from="-7d">
<div slot="fallback" aria-live="polite" role="status">
Loading top domains…
</div>
</TopDomainsPanel>
</section>
</body>
</html>---
// ✅ src/components/TopDomainsPanel.astro
import { nextdnsFetch } from '../lib/nextdns';
interface Props {
profileId: string;
from?: string;
}
const { profileId, from = '-7d' } = Astro.props;
const res = await nextdnsFetch<{
data: Array<{ domain: string; queries: number }>;
}>(`/profiles/${profileId}/analytics/domains?status=blocked&from=${from}&limit=10`);
const domains = res.data;
---
<table>
<thead>
<tr>
<th scope="col">Domain</th>
<th scope="col">Blocked queries</th>
</tr>
</thead>
<tbody>
{domains.map((d) => (
<tr>
<td>{d.domain}</td>
<td>{d.queries.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>---
// ✅ Defer multiple panels independently — they load in parallel
const { id } = Astro.params;
---
<!-- All three islands fetch in parallel on the server -->
<AnalyticsPanel server:defer profileId={id} from="-1h">
<p slot="fallback" aria-live="polite">Loading 1h stats…</p>
</AnalyticsPanel>
<AnalyticsPanel server:defer profileId={id} from="-24h">
<p slot="fallback" aria-live="polite">Loading 24h stats…</p>
</AnalyticsPanel>
<AnalyticsPanel server:defer profileId={id} from="-7d">
<p slot="fallback" aria-live="polite">Loading 7d stats…</p>
</AnalyticsPanel>// ✅ astro.config.mjs — server:defer requires SSR
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
// Server islands require output: 'server' or 'hybrid'
output: 'server',
adapter: node({ mode: 'standalone' }),
});For output: 'hybrid' (static by default), add export const prerender = false to pages that use
server islands.
---
// ❌ Do NOT call api.nextdns.io from a React island (client-side)
// src/components/react/AnalyticsPanel.tsx
import { useEffect } from 'react';
useEffect(() => {
fetch('https://api.nextdns.io/profiles/abc123/analytics/status', {
headers: { 'X-Api-Key': import.meta.env.PUBLIC_KEY }, // ❌ Key exposed
});
}, []);
---
<!-- ❌ Missing fallback slot — page shows blank area while loading -->
<AnalyticsPanel server:defer profileId={id} />
<!-- ❌ No fallback: users see empty space with no loading indication -->| Approach | TTFB | Analytics visible | Layout shift |
|---|---|---|---|
| Without server:defer | Slow (waits for all analytics) | At load | None |
| With server:defer | Fast (profile renders instantly) | ~1–3s later | Minimal (fallback placeholder) |
- Always provide a
fallbackslot: Without a fallback, users see blank space while the island loads, which is disorienting. - Use
aria-live="polite"on fallbacks: Screen readers announce when the content updates. - Defer only the slow panels: Panels backed by fast API calls (profile name, basic settings) do
not need
server:defer. - Keep island components focused: Each server island should fetch exactly the data it needs — avoid over-fetching inside a deferred component.
Symptoms: The page load time is unchanged after adding server:defer.
Solution: Confirm output: 'server' or output: 'hybrid' is set in astro.config.mjs. Server
Islands require an SSR runtime — they are not available in output: 'static' mode.
Symptoms: The deferred content appears to load synchronously.
Solution: This is expected if the server-side fetch is fast (under ~200ms). Add a small
artificial delay in development to see the fallback: await new Promise(r => setTimeout(r, 1000)).
Remove before deploying.