Skip to content

Component Architecture

This guide documents our established patterns for component architecture, using real examples from our codebase.

Our established pattern is to render components on the server by default. This delivers excellent performance, shipping minimal JavaScript to the client and enabling us to achieve fast load times, higher Lighthouse scores, and a better user experience.

We should only use client directives when client-side interactivity is absolutely necessary. While powerful, client-side rendering introduces additional JavaScript, hydration costs, and runtime complexity that can negatively impact performance if used indiscriminately.

  • .astro files: Excellent for template-heavy components, mixed content, and when you want to leverage Astro’s templating features
  • .tsx files: Great for logic-heavy components, when you prefer JSX syntax, or when you need TypeScript’s full expressiveness (like our icon components)
  • Performance-First Philosophy
    Astro encourages us to critically evaluate when client-side JavaScript is essential. Defaulting to server-side rendering helps us keep our applications lean and fast.

  • Consistency and Maintainability
    Clear rules reduce ambiguity in code reviews and ensure our codebase remains consistent and easy to scale.

  • The Cost of Client-Side JavaScript
    Every client directive adds JavaScript bundles, increasing parsing, execution, and debugging overhead. By limiting client-side rendering, we avoid unnecessary performance penalties.

Before adding a client directive to any component, ask:

  1. Does this feature require true client-side interactivity?
    (e.g., dynamic state, user-driven updates, or real-time behaviors)
  2. Can this be achieved with simple vanilla JavaScript in an Astro component?
    (e.g., basic DOM manipulation, simple event handlers, progressive enhancement)
  3. Is the client-side overhead justified by the benefit to the user experience?
  4. Can this be achieved with server-side rendering instead?

If the answer to #1 is no, render on the server. If #1 is yes but #2 is also yes, use Astro + vanilla JS. Only if #1 is yes, #2 is no, and #3 outweighs the performance cost, then client directives are acceptable.


TL;DR:

  • Server-render by default (both .astro and .tsx files).
  • ✅ For minimal interactivity, use Astro + vanilla JS.
  • ✅ Add client directives only when complex state/reactivity is needed.
  • ✅ Choose file type based on component needs: .astro for templates, .tsx for logic/JSX preference.
  • ❌ Don’t add client directives just because it’s convenient — make it intentional.

When creating a new component, use this flowchart to determine the appropriate rendering strategy:

Key Decision Points:

  • Interactivity: Does the component need client-side JavaScript?
  • Minimal JS: Can it be achieved with simple vanilla JavaScript (DOM manipulation, event listeners, no state management)?
  • Above Fold: Is the component visible on initial page load?
  • Critical: Is immediate interaction required once visible?

File Type Guidance:

  • Choose .astro: For template-heavy components, mixed content, front matter logic
  • Choose .tsx: For logic-heavy components, JSX preference, TypeScript expressiveness (like icons)

Common Pitfalls:

  • Adding client directives to static content
  • Using client-side rendering when vanilla JavaScript would suffice
  • Over-eager hydration with client:load
  • Missing loading states for client:visible
  • Inconsistent hydration strategies

Both .astro and .tsx files can be server-rendered with zero JavaScript cost to the client.

Use .astro components for:

  • Static content and layouts
  • Server-side data fetching
  • Complex template composition
  • Zero-JavaScript needs

Example from our codebase:

components/pageblock/pageblock.astro
interface Props { className?: string; flush?: boolean; inset?: boolean; } const {
className = "", flush = false, inset = false, ...rest } = Astro.props;
<section
class:list={[
"relative",
{ "my-12 md:mt-20": !flush && !inset },
{ "py-12 md:py-20": inset },
className,
]}
{...rest}
>
<slot />
</section>

🤔 Why This Pattern?

  • TypeScript interfaces for prop validation
  • Default prop values for flexibility
  • Utility-first CSS with conditional classes
  • Composition through slots

Use .tsx components (without client directives) for:

  • Logic-heavy server-side components
  • When you prefer JSX syntax over Astro templates
  • Components that benefit from TypeScript’s full expressiveness
  • Reusable UI elements like icons

Example from our codebase:

icons/ChevronRight.tsx
interface Props {
class?: string;
size?: number;
}
export const ChevronRight = (props: Props) => {
const { class: className = "", size = 24 } = props;
return (
<svg
class={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18l6-6-6-6" />
</svg>
);
};

Usage in Astro:

---
import { ChevronRight } from "#icons/ChevronRight";
---
<div class="navigation">
<a href="/next">
Next Page
<!-- This renders server-side, no JS cost -->
<ChevronRight size={16} class="inline-block ml-1" />
</a>
</div>

🤔 Why This Pattern?

  • Zero JavaScript bundle cost (renders server-side)
  • Full TypeScript support and IDE features
  • Clean JSX syntax for complex logic
  • Easy to test and reason about
  • Reusable across different contexts

Lightly Interactive Components (Astro + Script)

Section titled “Lightly Interactive Components (Astro + Script)”

Use Astro components with vanilla JavaScript for:

  • Simple DOM manipulation
  • Basic event handlers
  • Progressive enhancement
  • Lightweight interactions without state management

Example from our pattern:

components/accordion/accordion.astro
---
interface Props {
title: string;
defaultOpen?: boolean;
}
const { title, defaultOpen = false } = Astro.props;
---
<details class="accordion" data-default-open={defaultOpen}>
<summary class="accordion__trigger">{title}</summary>
<div class="accordion__content">
<slot />
</div>
</details>
<script>
// Progressive enhancement - works without JS
document.addEventListener('DOMContentLoaded', () => {
const accordions = document.querySelectorAll('.accordion[data-default-open="true"]');
accordions.forEach((accordion) => {
(accordion as HTMLDetailsElement).open = true;
});
});
</script>

🤔 When to Use This Pattern?

  • ✅ Simple click handlers and DOM manipulation
  • ✅ Progressive enhancement (works without JS)
  • ✅ No complex state management needed
  • ✅ One-off interactions
  • ❌ Complex state management (use SolidJS instead)
  • ❌ Reactive data updates (use SolidJS instead)
  • ❌ Component reusability across different contexts (use SolidJS instead)

Client-Side Interactive Components (.tsx + client directives)

Section titled “Client-Side Interactive Components (.tsx + client directives)”

Use .tsx components with client directives for:

  • User interactions requiring state management
  • Client-side state and reactivity
  • Complex event handling
  • Dynamic updates and real-time behaviors

Example from our codebase:

components/saveButton.tsx
interface Props {
type: string;
esid: string;
slug: string;
}
export const SaveButton: Component<Props> = (props) => {
const [saving, setSaving] = createSignal(false);
const [saved, setSaved] = createSignal(false);
async function save() {
try {
setSaving(true);
const response = await fetch(`/api/saves`, {
method: "POST",
body: JSON.stringify({
ref_type: props.type,
ref_id: props.esid,
ref_slug: props.slug,
}),
});
if (!response.ok) throw new Error("Failed to save");
setSaved(true);
} catch (err) {
const { airbrakeClient } = await import("#utils/airbrake/browser");
console.error("save#saveButton.tsx", err);
airbrakeClient.notify({
error: err,
context: { component: "saveButton.tsx#save" },
});
} finally {
setSaving(false);
}
}
return (
<button
onClick={save}
disabled={saving()}
class="btn btn-primary"
>
{saved() ? "Saved" : "Save"}
</button>
);
}

Usage in Astro (with client directive):

---
import { SaveButton } from "#components/saveButton";
---
<article>
<h1>Great Article</h1>
<p>Some content...</p>
<!-- Client directive required for interactivity -->
<SaveButton
client:visible
type="article"
esid="123"
slug="great-article"
/>
</article>

🤔 Why This Pattern?

  • TypeScript for type safety
  • SolidJS signals for state management
  • Structured error handling with Airbrake
  • Loading state management
  • Client directive enables interactivity (adds to JS bundle)

Our components follow a feature-based organization:

src/
├── components/ # Shared components
│ └── feature/
│ ├── index.astro # Main component
│ ├── Component.tsx # Interactive parts
│ └── utils.ts # Helper functions
├── pages/ # Routes and pages
│ └── route/
│ ├── _components/ # Route-specific components
│ └── index.astro # Page template

🤔 Why This Pattern?

  • Clear separation of concerns
  • Colocated related files
  • Easy to find and maintain
  • Scalable for large features

We compose components using Astro’s Island Architecture:

---
import { PageLayout } from "#components/layout";
import { StaticHeader } from "#components/header";
import { InteractiveFeature } from "#components/feature";
import { StaticFooter } from "#components/footer";
---
<PageLayout>
<StaticHeader />
<!-- Critical interactive feature -->
<InteractiveFeature client:load />
<!-- Below-fold interactive features -->
<InteractiveFeature client:visible />
<!-- Non-critical features -->
<InteractiveFeature client:idle />
<StaticFooter />
</PageLayout>

🤔 Why This Pattern?

  • Zero JavaScript by default
  • Strategic hydration
  • Performance optimization
  • Clear loading priorities

We use structured error handling with Airbrake:

try {
await operation();
} catch (err) {
// Expected errors are logged to stdout via pino
console.error("Operation failed", {
error: err.message,
component: "ComponentName",
});
// Unexpected errors are monitored via Airbrake
const { airbrakeClient } = await import("#utils/airbrake/browser");
airbrakeClient.notify({
error: err,
context: { component: "ComponentName" },
});
}

🤔 Why This Pattern?

  • Clear distinction between expected and unexpected errors
  • Structured logging for aggregation
  • Error monitoring for critical issues
  • Context preservation

We implement consistent loading states:

const Component = () => {
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal();
const [data, setData] = createSignal();
return (
<Show
when={!loading()}
fallback={<LoadingSpinner />}
>
<Show
when={!error()}
fallback={<ErrorMessage error={error()} />}
>
<div>{data()}</div>
</Show>
</Show>
);
};

🤔 Why This Pattern?

  • Clear loading indicators
  • Error state handling
  • Fallback UI components
  • Consistent user experience

Don’t: Mix concerns in a single component

// Bad: Mixed concerns
const Component = () => {
const [state, setState] = createSignal();
const utils = makeUtils();
return <div>{state()}</div>;
};

Do: Separate concerns

// Good: Separated concerns
import { useUtils } from "./utils";
const Component = () => {
const [state, setState] = createSignal();
const utils = useUtils();
return <div>{state()}</div>;
};

Don’t: Add client directives to static content

<!-- Bad: Unnecessary JavaScript bundle -->
<StaticContent client:load />
<IconComponent client:visible />

Do: Server-render by default, add client directives only when needed

<!-- Good: Server-rendered by default -->
<StaticContent />
<IconComponent />
<!-- Only add client directive when interactivity is needed -->
<InteractiveFeature client:visible />

When creating a new component, ask:

  1. Rendering Strategy

    • Does it need client-side interactivity?
    • Does it require client-side state management?
    • Can vanilla JavaScript handle the interactions?
    • Should it render server-side only?
  2. File Type Choice

    • Is it template-heavy? (consider .astro)
    • Is it logic-heavy? (consider .tsx)
    • Do you prefer JSX syntax? (use .tsx)
    • Do you need Astro’s features? (use .astro)
  3. Client Directive Strategy (if client-side rendering needed)

    • Is it critical above-fold? (use client:load)
    • Can it load when visible? (use client:visible)
    • Is it non-critical? (use client:idle)
  4. Location

    • Is it shared across routes?
    • Is it route-specific?
    • Does it belong to a feature?
  5. Error Handling

    • Are errors properly caught?
    • Is error reporting implemented?
    • Are fallbacks provided?
  6. Performance

    • Are client directives minimized?
    • Are images optimized?
    • Are loading states added?