Component Architecture
This guide documents our established patterns for component architecture, using real examples from our codebase.
Default to Server-Side Rendering
Section titled “Default to Server-Side Rendering”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.
When Client-Side Rendering is Appropriate
Section titled “When Client-Side Rendering is Appropriate”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.
File Type Considerations
Section titled “File Type Considerations”.astrofiles: Excellent for template-heavy components, mixed content, and when you want to leverage Astro’s templating features.tsxfiles: Great for logic-heavy components, when you prefer JSX syntax, or when you need TypeScript’s full expressiveness (like our icon components)
Why This Matters
Section titled “Why This Matters”-
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.
Decision Framework
Section titled “Decision Framework”Before adding a client directive to any component, ask:
- Does this feature require true client-side interactivity?
(e.g., dynamic state, user-driven updates, or real-time behaviors) - Can this be achieved with simple vanilla JavaScript in an Astro component?
(e.g., basic DOM manipulation, simple event handlers, progressive enhancement) - Is the client-side overhead justified by the benefit to the user experience?
- 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
.astroand.tsxfiles). - ✅ For minimal interactivity, use Astro + vanilla JS.
- ✅ Add client directives only when complex state/reactivity is needed.
- ✅ Choose file type based on component needs:
.astrofor templates,.tsxfor logic/JSX preference. - ❌ Don’t add client directives just because it’s convenient — make it intentional.
Quick Decision Guide
Section titled “Quick Decision Guide”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
Component Types
Section titled “Component Types”Server-Rendered Components
Section titled “Server-Rendered Components”Both .astro and .tsx files can be server-rendered with zero JavaScript cost to the client.
Static Astro Components (.astro)
Section titled “Static Astro Components (.astro)”Use .astro components for:
- Static content and layouts
- Server-side data fetching
- Complex template composition
- Zero-JavaScript needs
Example from our codebase:
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
Server-Rendered TSX Components (.tsx)
Section titled “Server-Rendered TSX Components (.tsx)”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:
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:
---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:
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)
Component Organization
Section titled “Component Organization”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
Component Composition
Section titled “Component Composition”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
Error Handling
Section titled “Error Handling”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
Loading States
Section titled “Loading States”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
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”❌ Don’t: Mix concerns in a single component
// Bad: Mixed concernsconst Component = () => { const [state, setState] = createSignal(); const utils = makeUtils(); return <div>{state()}</div>;};✅ Do: Separate concerns
// Good: Separated concernsimport { 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 />Decision Checklist
Section titled “Decision Checklist”When creating a new component, ask:
-
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?
-
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)
- Is it template-heavy? (consider
-
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)
- Is it critical above-fold? (use
-
Location
- Is it shared across routes?
- Is it route-specific?
- Does it belong to a feature?
-
Error Handling
- Are errors properly caught?
- Is error reporting implemented?
- Are fallbacks provided?
-
Performance
- Are client directives minimized?
- Are images optimized?
- Are loading states added?