@stnd/store
The Standard runtime store. A lightweight provide/inject service registry that acts as the nervous system of the framework — shared reactive state without props drilling, no coupling between modules.
Any module can provide a value (a Svelte store, a function, a primitive). Any component or module can inject it back by key. The store lives for the lifetime of the page.
Why this exists
Astro’s architecture separates server rendering from client-side islands. In a multi-module system, you have many independent components that need access to the same runtime state (who’s logged in, the search function, the dialog controller) without knowing about each other.
You cannot:
- Pass props across Astro’s server/client boundary to every island
- Import a singleton store directly (it would work, but bypasses the module system and makes testing and overriding impossible)
- Use Svelte context (only works within a component tree)
@stnd/store solves this with a simple contract:
- Something that knows the value registers it with
provide(key, value)— once, at boot - Everything that needs it reads it with
inject(key)— anywhere, anytime
Install
pnpm add @stnd/store
API
provide(key, value)
Register any value under a well-known key. Call this in boot scripts or module init code, before components mount.
import { provide } from “@stnd/store”;
import { userStore } from “@stnd/account/models/User.js”;
provide(“user”, userStore);
Calling provide twice with the same key overwrites the previous value and logs a warning.
inject(key, fallback?)
Retrieve a registered value. Returns the value, or fallback (default: null) if nothing was provided.
import { inject } from “@stnd/store”;
const userStore = inject(“user”);
const search = inject(“search”, async () => []);
Injecting a missing key with no fallback logs a warning. This is intentional — it helps you catch timing issues where a consumer mounts before the provider has run.
has(key)
Check whether a key has been registered without triggering a warning.
import { has } from “@stnd/store”;
if (has(“search”)) {
// safe to inject
}
revoke(key)
Remove a key from the store. Returns true if the key existed.
import { revoke } from “@stnd/store”;
revoke(“search”);
entries()
List all registered entries. Useful for debugging what’s available at runtime.
import { entries } from “@stnd/store”;
console.log(entries());
// [[“user”, atom(…)], [“search”, fn], [“dialog”, {…}]]
clear()
Wipe the entire store. Use in tests only — not in application code.
import { clear } from “@stnd/store”;
clear();
The module manifest shortcut
The preferred way to register stores is through a module manifest store: field. The framework auto-generates the provide() calls at build time via virtual:stnd/store — you never write the boot code manually.
// packages/account/index.module.js
export default {
id: “stnd-account”,
store: {
user: “./models/User.js#userStore”,
},
};
// apps/stnd.gd/modules/launcher/index.module.js
export default {
id: “launcher”,
store: {
search: “./search.js#searchNotes”,
},
};
The #exportName suffix selects a named export. Without it, the default export is used.
This is wired automatically — StndInit.astro imports virtual:stnd/store in a client <script>, which runs before any island mounts.
Usage in Astro
Server side (.astro files)
The store is client-only. Do not import @stnd/store in the frontmatter of Astro files — that runs on the server where the store doesn’t exist yet.
—
// ✗ Wrong — this runs on the server
import { inject } from “@stnd/store”;
const user = inject(“user”); // always null on the server
—
For server-side data, use Astro’s Astro.locals (populated by middleware) or read directly from your models.
—
// ✓ Correct — read server state from locals
const user = Astro.locals.user;
—
Client islands (.astro <script> blocks)
You can safely call inject inside a <script> tag — this runs on the client after virtual:stnd/store has already called provide.
<button id=“logout”>Log out</button>
<script>
import { inject } from “@stnd/store”;
const userStore = inject(“user”);
document.getElementById(“logout”).addEventListener(“click”, () => {
// userStore is a nanostores atom
console.log(“Logging out”, userStore.get());
});
</script>
Make sure StndInit.astro (or equivalent) is included in your base layout so virtual:stnd/store runs first.
Usage in Svelte
In a Svelte 5 component (recommended)
<script>
import { inject } from “@stnd/store”;
import { useStore } from “nanostores”;
// inject returns the nanostores atom itself
const userStore = inject(“user”);
const user = useStore(userStore);
</script>
{#if $user}
<p>Hello, {$user.name}</p>
{:else}
<p>Not logged in</p>
{/if}
With a fallback for optional services
<script>
import { inject } from “@stnd/store”;
// The search function may not exist on all apps
const search = inject(“search”, async () => []);
let query = $state(“”);
let results = $state([]);
async function handleInput() {
results = await search(query);
}
</script>
Accessing the dialog service
@stnd/ui/dialog.js registers itself under the “dialog” key on load. Any Svelte component can trigger a confirmation without importing from @stnd/ui directly:
<script>
import { inject } from “@stnd/store”;
const dialog = inject(“dialog”);
async function handleDelete() {
const confirmed = await dialog.confirm({
title: “Delete this item?“,
description: “This cannot be undone.“,
intent: “danger”,
confirmLabel: “Delete”,
});
if (confirmed) {
// proceed
}
}
</script>
<button onclick={handleDelete}>Delete</button>
Providing your own service
If a module needs to expose a runtime service to other modules, call provide in a boot script.
// modules/search/client/boot.js
import { provide } from “@stnd/store”;
let index = null;
async function loadIndex() {
const res = await fetch(“/search-index.json”);
index = await res.json();
}
async function searchContent(query) {
if (!index) await loadIndex();
return index.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
);
}
provide(“search”, searchContent);
Or, if the service is a reactive Svelte store:
// modules/theme/client/boot.js
import { provide } from “@stnd/store”;
import { atom } from “nanostores”;
export const themeStore = atom(“light”);
provide(“theme”, themeStore);
Then register it via the module manifest (preferred over manual boot scripts):
// modules/theme/index.module.js
export default {
id: “theme”,
store: {
theme: “./client/boot.js#themeStore”,
},
};
Timing: the boot order
The store is populated in this order on every page load:
1. StndInit.astro runs <script> on the client
└── imports virtual:stnd/store
└── calls provide() for every manifest store: entry
2. Module boot scripts run (declared via scripts: in manifests)
└── may call provide() for dynamic services
3. Svelte islands mount
└── call inject() to consume what was provided
If you call inject(“user”) in a Svelte component’s <script> and get null, it almost always means virtual:stnd/store hasn’t run yet — check that StndInit.astro is included in your base layout before any island.
Logging
All store operations emit @stnd/log debug messages tagged [store]. To see them, set the log level to DEBUG:
STND_LOG_LEVEL=DEBUG astro dev
Example output:
::stnd:: [store] [provide] “user”
::stnd:: [store] [provide] “search”
::stnd:: [store] [inject] “user”
::stnd:: [store] [inject] “search”
Overwriting a key or injecting a missing key emits a warning at the WARN level, visible in all environments:
::stnd:: ⚠ [store] [provide] overwriting existing key: “user”
::stnd:: ⚠ [store] [inject] “theme” not found — returning null
This is your first clue when something is provided twice (a module manifest and a boot script both registering the same key) or when a component tries to consume a service that was never registered.
Known keys
These keys are registered by @stnd packages and should not be reused:
| Key | Type | Provided by |
|---|---|---|
“user” |
nanostores atom |
@stnd/account via module manifest |
“search” |
async (query: string) => Result[] |
App-specific launcher module |
“dialog” |
{ confirm, state } |
@stnd/ui/dialog.js on import |
License
MIT