Export a loader() function from any page or layout to fetch data on the server before rendering.
// pages/dashboard.ts export async function loader({ params, query, url, headers }) { const data = await fetch('https://api.example.com/stats'); return { stats: await data.json() }; } export class PageDashboard extends LitElement { static properties = { stats: { type: Object } }; stats: any = null; render() { return html`<p>Users: ${this.stats?.users}</p>`; } }
The loader receives a context object with:
{
params: {}, // Dynamic route parameters
query: {}, // URL query string parameters
url: '/dashboard', // Current pathname
headers: {}, // Request headers
locale: 'en' // Current locale (when i18n is configured)
}
Return a redirect from a loader using the redirect() helper:
import { redirect } from 'lumenjs/response'; export async function loader({ headers }) { const token = headers['authorization']; if (!token) { return redirect('/login', 302); } return { user: await getUser(token) }; }
On the initial page load, the loader runs on the server and the result is:
1. Used for SSR rendering (the component renders with real data)
2. Serialized as JSON in the HTML (for client hydration)
3. On client-side navigation, the loader runs via /__nk_loader/ API endpoint
Each key in the loader's return object is automatically spread as an individual reactive property on your component. Declare a matching property for each key:
// pages/dashboard.ts export async function loader() { return { stats: await fetchStats(), title: 'Dashboard', }; } export class PageDashboard extends LitElement { static properties = { stats: { type: Array }, title: { type: String }, }; render() { return html` <h1>${this.title}</h1> <p>Users: ${this.stats?.length}</p> `; } }
Layouts can also export loaders. Useful for auth checks, global data, etc.:
// pages/_layout.ts export async function loader() { return { appName: 'My App', year: new Date().getFullYear() }; }
_loader.ts)When a loader grows large, move it into a _loader.ts file alongside the page. For folder routes (index.ts pages), LumenJS discovers it automatically — no import or wrapper needed in the page file.
pages/
└── dashboard/
├── index.ts ← page component only
└── _loader.ts ← loader, auto-discovered
// pages/dashboard/_loader.ts export async function loader({ user }) { const [stats, activity] = await Promise.all([ db.getStats(user.id), db.getActivity(user.id), ]); return { stats, activity }; }
// pages/dashboard/index.ts — no loader here export class PageDashboard extends LitElement { static properties = { stats: { type: Array }, activity: { type: Array }, }; render() { return html`...`; } }
loader(), it takes precedence. Co-located _loader.ts only applies to folder routes — flat pages like pages/about.ts keep the loader inline.
All three patterns work in the same project — choose whichever fits the size of your loader:
// 1. Inline — best for small loaders export async function loader({ params }) { return { post: await getPost(params.id) }; } export class PagePost extends LitElement { ... }
// 2. Wrapper to _lib/loader.ts — existing split pattern, still works export async function loader(ctx) { const { loader: fn } = await import('./_lib/loader.js'); return fn(ctx); } export class PagePost extends LitElement { ... }
// 3. _loader.ts co-location — no wrapper, page file has no loader at all // pages/post/_loader.ts → loader lives here // pages/post/index.ts → only the component export class PagePost extends LitElement { ... }
Loaders are not limited to pages. Any Lit component anywhere in the project can export a loader() — same syntax, same server-side guarantees, same auto-stripping from client bundles. No mixin, no file path string, no wiring.
// components/site-nav.ts export async function loader({ locale, user }) { const db = useDb(); const navItems = await db.all( 'SELECT * FROM nav_items WHERE locale = ?', locale ); return { navItems, userName: user?.name ?? 'Guest' }; } export class SiteNav extends LitElement { static properties = { navItems: { type: Array }, userName: { type: String }, }; navItems = []; userName = 'Guest'; render() { return html` <nav> <span>Hello, ${this.userName}</span> ${this.navItems.map(i => html`<a href="${i.href}">${i.label}</a>`)} </nav> `; } }
The Vite transform detects the loader() and automatically:
1. Strips it from the client bundle
2. Patches connectedCallback to auto-fetch data on mount
During SSR, component loaders are discovered from the page module's import graph. If a page imports a component with a loader(), LumenJS:
1. Runs the component's loader server-side
2. Sets the data on the component class prototype
3. Lit SSR renders it with data already present
4. Data is inlined in the HTML for hydration — no client-side fetch on initial load
SSR: loader() → data on prototype → Lit renders → HTML sent Hydration: read __nk_ssr_data__ → set on DOM element → module loads → no fetch CSR nav: connectedCallback → fetch /__nk_loader/__component/ → spread → requestUpdate()
Component loaders receive the same context as page loaders:
{
params: {}, // Always {} for components (no URL segments)
query: {}, // Extra query parameters
url: '/dashboard', // Current page pathname
headers: {}, // Request headers
locale: 'en', // Current locale (i18n)
user: { ... } // Authenticated user (auth)
}