Chapter 163 · Omnibus Instrument Product Analytics
Subchapter 163.7
references/EXAMPLE-astro-hybrid.mdMarkdown27 KBView on GitHub
Repository: https://github.com/PostHog/context-mill Path: basics/astro-hybrid
This is an Astro (opens in a new tab) hybrid rendering example demonstrating PostHog integration with both static and on-demand rendered pages.
Hybrid mode allows you to have most pages prerendered (static) while opting specific pages into server-side rendering (SSR) when needed.
It uses:
posthog-node for API route event trackingThis shows how to:
export const prerender = falseposthog-nodenpm install
# or
pnpm installCreate a .env file in the project root:
PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
PUBLIC_POSTHOG_HOST=https://us.i.posthog.comGet your PostHog project token from your project settings in PostHog.
npm run dev
# or
pnpm devOpen http://localhost:4321 in your browser.
src/
components/
posthog.astro # PostHog snippet for client-side tracking
Header.astro # Navigation + logout, calls posthog.reset()
layouts/
PostHogLayout.astro # Root layout that includes PostHog + Header
lib/
auth.ts # Client-side auth utilities
posthog-server.ts # Server-side PostHog client singleton
pages/
index.astro # Static (prerendered) - login form
burrito.astro # SSR (prerender=false) - calls API routes
profile.astro # Static (prerendered) - user profile
api/
auth/
login.ts # Server-side login endpoint with PostHog tracking
events/
burrito.ts # Server-side event capture endpoint
styles/
global.css # Global stylesIn Astro 5, output: 'static' is the default and supports per-page SSR opt-in. You need an adapter for the SSR pages to work:
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
// 'static' is the default - pages are prerendered unless they opt out
output: "static",
adapter: node({ mode: "standalone" }),
});---
// Opt this page into on-demand rendering (SSR)
// In hybrid mode, pages are static by default
export const prerender = false;
---A singleton pattern ensures only one PostHog client is created:
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
export function getPostHogServer(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, {
host: import.meta.env.PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}import { getPostHogServer } from "../../../lib/posthog-server";
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const sessionId = request.headers.get("X-PostHog-Session-Id");
const posthog = getPostHogServer();
posthog.capture({
distinctId: body.username,
event: "burrito_considered",
properties: {
$session_id: sessionId || undefined,
source: "api",
},
});
return new Response(JSON.stringify({ success: true }));
};Use hybrid mode when you want:
# Run dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run previewPUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
// In Astro 5, 'static' is the default and supports per-page SSR opt-in
// Use `export const prerender = false` in pages that need server rendering
output: "static",
adapter: node({
mode: "standalone",
}),
image: {
service: { entrypoint: "astro/assets/services/noop" },
},
});
---
// Header component with navigation and logout functionality
---
<header class="header">
<div class="header-container">
<nav>
<a href="/">Home</a>
<a href="/burrito" class="auth-link" style="display: none;">Burrito Consideration</a
---
// PostHog analytics snippet for client-side tracking
// Uses is:inline to prevent Astro from processing the script
---
<script is:inline define:vars={{ apiKey: import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.PUBLIC_POSTHOG_HOST }}>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=
posthog.init(apiKey || '', {
api_host: apiHost || 'https://us.i.posthog.com',
defaults: '2026-01-30'
})
</script>
---
import PostHog from '../components/posthog.astro';
import Header from '../components/Header.astro';
import '../styles/global.css';
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Astro PostHog SSR Integration Example" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<PostHog />
</head>
<body>
<Header />
<main>
<slot />
</main>
</body>
</html>
// Client-side auth utilities for localStorage-based authentication
export interface User {
username: string;
burritoConsiderations: number;
}
export function getCurrentUser(): User | null {
if (typeof window === "undefined") return null;
const username = localStorage.getItem("currentUser");
if (!username) return null;
const considerations = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
return {
username,
burritoConsiderations: considerations,
};
}
export function login(username: string, password: string): boolean {
if (!username || !password) return false;
localStorage.setItem("currentUser", username);
// Initialize burrito considerations if not set
if (!localStorage.getItem("burritoConsiderations")) {
localStorage.setItem("burritoConsiderations", "0");
}
return true;
}
export function logout(): void {
localStorage.removeItem("currentUser");
localStorage.removeItem("burritoConsiderations");
}
export function incrementBurritoConsiderations(): number {
const current = parseInt(
localStorage.getItem("burritoConsiderations") || "0",
10,
);
const newCount = current + 1;
localStorage.setItem("burritoConsiderations", newCount.toString());
return newCount;
}
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
/**
* Get the PostHog server-side client.
* Uses a singleton pattern to avoid creating multiple clients.
*/
export function getPostHogServer(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(import.meta.env.PUBLIC_POSTHOG_PROJECT_TOKEN || "", {
host: import.meta.env.PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
// Flush immediately for demo purposes
// In production, you might want to batch events
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}
/**
* Shutdown the PostHog client gracefully.
* Call this when your server is shutting down.
*/
export async function shutdownPostHog(): Promise<void> {
if (posthogClient) {
await posthogClient.shutdown();
posthogClient = null;
}
}
import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";
export const prerender = false;
// In-memory user store for demo purposes
const users = new Map<string, { username: string; createdAt: string }>();
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return new Response(
JSON.stringify({ error: "Username and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Check if this is a new user
const isNewUser = !users.has(username);
if (isNewUser) {
users.set(username, {
username,
createdAt: new Date().toISOString(),
});
}
// Get the PostHog server client
const posthog = getPostHogServer();
// Get session ID from client if available (passed via header)
const sessionId = request.headers.get("X-PostHog-Session-Id");
// Capture server-side login event
posthog.capture({
distinctId: username,
event: "server_login",
properties: {
$session_id: sessionId || undefined,
isNewUser,
source: "api",
timestamp: new Date().toISOString(),
},
});
// Also identify the user server-side
posthog.identify({
distinctId: username,
properties: {
username,
createdAt: isNewUser ? new Date().toISOString() : undefined,
},
});
return new Response(
JSON.stringify({
success: true,
username,
isNewUser,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Login error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
import type { APIRoute } from "astro";
import { getPostHogServer } from "../../../lib/posthog-server";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { username, totalConsiderations } = body;
if (!username) {
return new Response(JSON.stringify({ error: "Username is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Get the PostHog server client
const posthog = getPostHogServer();
// Get session ID from client if available (passed via header)
const sessionId = request.headers.get("X-PostHog-Session-Id");
// Capture server-side burrito consideration event
posthog.capture({
distinctId: username,
event: "burrito_considered",
properties: {
$session_id: sessionId || undefined,
total_considerations: totalConsiderations,
source: "api",
timestamp: new Date().toISOString(),
},
});
return new Response(
JSON.stringify({
success: true,
totalConsiderations,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Burrito event error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
// Opt this page into on-demand rendering (SSR)
// In hybrid mode, pages are static by default
export const prerender = false;
---
<PostHogLayout title="Burrito Consideration - Astro PostHog Hybrid Example">
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
// This page is prerendered (static) by default in hybrid mode
// No need to set prerender = true explicitly
---
<PostHogLayout title="Home - Astro PostHog Hybrid Example">
<div class="container">
<div id="logged-in-view" style="display: none;">
<h1>Welcome back, <span id="welcome-username"
---
import PostHogLayout from '../layouts/PostHogLayout.astro';
// This page is prerendered (static) by default in hybrid mode
---
<PostHogLayout title="Profile - Astro PostHog Hybrid Example">
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> <span id="profile-username"></span></p>
<p><strong>Burrito Considerations:</strong> <span id="profile-considerations">0</span></p>
</div>
<div style="margin-top: 2rem;">
<h3>Your Burrito Journey</h3>
<p id="journey-message"></p>
</div>
<div style="margin-top: 2rem;">
<h3>Error Tracking Demo</h3>
<p>Click the button below to trigger a test error and send it to PostHog:</p>
<button id="error-btn" class="btn-error">
Trigger Test Error
</button>
<p id="error-feedback" class="success" style="display: none;">
Error captured and sent to PostHog!
</p>
</div>
</div>
</PostHogLayout>
<script is:inline>
function checkAuth() {
const currentUser = localStorage.getItem('currentUser');
if (!currentUser) {
window.location.href = '/';
return false;
}
return true;
}
function updateProfile() {
const username = localStorage.getItem('currentUser') || '';
const considerations = parseInt(localStorage.getItem('burritoConsiderations') || '0', 10);
document.getElementById('profile-username').textContent = username;
document.getElementById('profile-considerations').textContent = considerations;
// Update journey message based on consideration count
const journeyMessage = document.getElementById('journey-message');
if (considerations === 0) {
journeyMessage.textContent = "You haven't considered any burritos yet. Visit the Burrito Consideration page to start!";
} else if (considerations === 1) {
journeyMessage.textContent = "You've considered the burrito potential once. Keep going!";
} else if (considerations < 5) {
journeyMessage.textContent = "You're getting the hang of burrito consideration!";
} else if (considerations < 10) {
journeyMessage.textContent = "You're becoming a burrito consideration expert!";
} else {
journeyMessage.textContent = "You are a true burrito consideration master!";
}
}
function triggerTestError() {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
// Capture the error in PostHog
window.posthog?.captureException(err);
console.error('Captured error:', err);
// Show feedback to user
const feedback = document.getElementById('error-feedback');
feedback.style.display = 'block';
setTimeout(() => {
feedback.style.display = 'none';
}, 3000);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!checkAuth()) return;
updateProfile();
document.getElementById('error-btn')?.addEventListener('click', triggerTestError);
});
</script>