The Astro Shell
What we’re building
Section titled “What we’re building”The static frame the whole app lives inside. In this lesson apps/web gets a reusable layout (AppShell.astro) that renders the HTML document — <head>, viewport, theme color, global styles — and a single route (index.astro) that mounts one custom element, <offline-notes-app>. We also register a minimal version of that element so you can see it upgrade in the browser. It renders just a header for now; the next lesson grows it into the real two-pane UI.
OfflineNotes is a single-page app that happens to be built with a static-site tool. There are no server routes to render per request — the app boots once, then runs entirely in the browser against IndexedDB. So Astro’s job here is narrow and deliberate: emit one static HTML shell that a Service Worker can precache verbatim, and hand control to custom elements as soon as it loads.
- The layout owns the document. Everything that belongs in
<head>— charset, viewport,theme-color, later the manifest link — lives in oneAppShell.astro, so every page (there’s really one) is consistent and the PWA metadata has a single home. - The route is a mount point, not a UI.
index.astrorenders<offline-notes-app>and nothing else. The actual interface is defined in TypeScript custom elements, kept out of Astro’s component model so it’s plain-web-platform portable and easy to reason about offline. - The root is a custom element, not a
<div>. Using<offline-notes-app>as the top-level tag means the app bootstraps itself inconnectedCallback— no imperativemount(document.getElementById('app'))glue, and the same lifecycle every child component uses.
Pros & cons
Section titled “Pros & cons”Astro static shell vs. a client-rendered SPA framework (React/Vue) for the shell
- Pros: The shell is real HTML on first paint and cache-friendly for the Service Worker; zero framework runtime ships for the frame itself; the
<head>/PWA metadata has one authoritative source. - Cons: Two mental models coexist (
.astrobuild-time components vs. runtime custom elements); Astro’s richer features (islands, SSR) go unused, so some of the tool is dead weight here.
A custom element (<offline-notes-app>) as the app root vs. mounting into a plain <div id="app">
- Pros: Self-bootstrapping via the standard element lifecycle; no imperative wiring; symmetrical with every child component.
- Cons: Nothing renders until the defining script loads and the element upgrades, so you must design for the un-upgraded state (a flash of empty frame) rather than server-rendered content.
Set it up
Section titled “Set it up”1. apps/web/src/layouts/AppShell.astro
Section titled “1. apps/web/src/layouts/AppShell.astro”The document shell. It takes a title prop, sets the PWA-friendly <head>, ships a little global CSS, and drops the page’s content into a <slot />.
---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" /> <meta name="theme-color" content="#0D9488" /> <title>{title}</title> </head> <body> <slot /> </body></html>
<style is:global> :root { --accent: #0d9488; font-family: system-ui, sans-serif; } * { box-sizing: border-box; } body { margin: 0; } .app-header { padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; } .app-header h1 { margin: 0; font-size: 1.1rem; color: var(--accent); }</style>is:global on the <style> tells Astro not to scope those rules — our custom elements render in the light DOM, so they need the styles to reach them.
2. apps/web/src/scripts/offline-notes-app.ts
Section titled “2. apps/web/src/scripts/offline-notes-app.ts”The root custom element. connectedCallback runs when the browser inserts the element, and we render the shell’s chrome into it. It’s intentionally thin now — a header and an empty body the next lesson fills.
export class OfflineNotesApp extends HTMLElement { connectedCallback() { this.innerHTML = ` <header class="app-header"><h1>OfflineNotes</h1></header> <main class="app-body"> <!-- <note-list> and <note-editor> mount here next lesson --> </main> `; }}
customElements.define('offline-notes-app', OfflineNotesApp);3. apps/web/src/pages/index.astro
Section titled “3. apps/web/src/pages/index.astro”The single route. It uses the layout, renders the root element, and — via a client <script> — imports the module that defines it. Astro bundles that script and ships it to the browser; the import’s side effect is the customElements.define call.
---import AppShell from '../layouts/AppShell.astro';---<AppShell title="OfflineNotes"> <offline-notes-app></offline-notes-app></AppShell>
<script> import '../scripts/offline-notes-app.ts';</script>This replaces the throwaway smoke-test page from the previous module.
Verify
Section titled “Verify”Start the dev server from the repo root (this also rebuilds the WASM package first):
pnpm devOpen http://localhost:4321/. You should see the OfflineNotes header rendered in the teal accent color. Open DevTools → Elements and confirm the root element upgraded — it now contains the header markup rather than being empty:
<offline-notes-app> <header class="app-header"><h1>OfflineNotes</h1></header> <main class="app-body">…</main></offline-notes-app>Now the run check — produce the static build the Service Worker will later precache:
pnpm buildExpected: Astro reports a successful build with one page emitted to apps/web/dist/:
▶ src/pages/index.astro └─ /index.html (+NNms) astro Complete!Check your understanding:
index.astrorenders<offline-notes-app></offline-notes-app>, but the element’s content appears only after the page loads. What has to happen between HTML parse and content appearing?- Why is the layout’s
<style>markedis:global— what would break without it, given where our components render? - The route contains no UI beyond one tag. Where does the actual interface come from, and why keep it out of
.astrofiles? - Why does the
<head>(viewport,theme-color) belong in the layout rather than the custom element?
apps/web now has a real shell: AppShell.astro owns the document and global styles, index.astro is the single route, and <offline-notes-app> mounts and upgrades into a self-rendering root element. It’s a frame with a header — the structure the whole UI hangs off.
Next, define the components that fill that frame — the root composing <note-list> and <note-editor> skeletons: First Web Components →.