Next.js 15 App Router Integration
apps/docs/src/content/docs/framework-integration/nextjs Click to copy apps/docs/src/content/docs/framework-integration/nextjs Next.js 15 App Router Integration
Section titled “Next.js 15 App Router Integration”Next.js 15 App Router introduces unique challenges for web components: Server Components render on the server where custom element APIs don’t exist, and Lit’s browser-only lifecycle requires careful boundary placement. This guide covers every pattern you need to integrate HELiX reliably.
Installation
Section titled “Installation”npm install @helixui/libraryIf you want React wrapper components with full prop typing (optional), also install @helixui/react:
npm install @helixui/reactThe 'use client' Boundary
Section titled “The 'use client' Boundary”Custom element registration (customElements.define) is a browser-only API. Any module that imports HELiX components must be a Client Component.
What requires 'use client'
Section titled “What requires 'use client'”- Any file that imports
@helixui/libraryor individual components - Any component that listens to HELiX custom events (
hx-click,hx-change, etc.) - Any component that holds a
refto a HELiX element
What does NOT require 'use client'
Section titled “What does NOT require 'use client'”- Server Components that only render HELiX tag names as JSX — the HTML is valid, the element upgrades on the client
- Layout files that only pass children through
Pattern: Client Boundary at the Loader
Section titled “Pattern: Client Boundary at the Loader”Register all components once in a dedicated Client Component at the root:
'use client';
import { useEffect } from 'react';
export function HelixLoader() { useEffect(() => { // Dynamically import so the registration only runs in the browser import('@helixui/library'); }, []);
return null;}// app/layout.tsx (Server Component — no 'use client' needed here)import { HelixLoader } from '@/components/helix-loader';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <HelixLoader /> {children} </body> </html> );}With this pattern, Server Components can render HELiX tag names freely. The custom element upgrades after hydration without warnings.
Pattern: Client Boundary per Interactive Component
Section titled “Pattern: Client Boundary per Interactive Component”For interactive components that need event handlers, create a thin Client Component wrapper:
'use client';
import { useRef, useEffect } from 'react';import '@helixui/library/components/hx-button';
interface SaveButtonProps { onSave: () => void; label?: string;}
export function SaveButton({ onSave, label = 'Save' }: SaveButtonProps) { const ref = useRef<HTMLElement>(null);
useEffect(() => { const el = ref.current; if (!el) return; el.addEventListener('hx-click', onSave); return () => el.removeEventListener('hx-click', onSave); }, [onSave]);
return <hx-button ref={ref} variant="primary">{label}</hx-button>;}The parent Server Component passes data down; the Client Component handles browser events:
// app/dashboard/page.tsx (Server Component)import { SaveButton } from '@/components/save-button';
export default function DashboardPage() { return ( <main> <h1>Dashboard</h1> <SaveButton label="Save changes" onSave={async () => { 'use server'; /* ... */ }} /> </main> );}SSR-Safe Patterns with next/dynamic
Section titled “SSR-Safe Patterns with next/dynamic”Use next/dynamic with ssr: false when a component imports HELiX at module scope (outside useEffect) or relies on window/document during render:
import dynamic from 'next/dynamic';
const HeavyHelixForm = dynamic( () => import('@/components/helix-form').then((m) => m.HelixForm), { ssr: false, loading: () => <div aria-busy="true">Loading form…</div>, });
export default function SomePage() { return ( <main> <HeavyHelixForm /> </main> );}When to use ssr: false vs the HelixLoader pattern:
| Approach | When to use |
|---|---|
HelixLoader + useEffect import | Global registration, components used across many pages |
next/dynamic with ssr: false | Heavy page-specific components, progressive enhancement |
Direct module import with 'use client' | Simple interactive islands with tight coupling |
TypeScript Configuration
Section titled “TypeScript Configuration”tsconfig.json adjustments
Section titled “tsconfig.json adjustments”HELiX ships types for every component. Add the library to your types or rely on automatic discovery via node_modules/@helixui/library/:
{ "compilerOptions": { "strict": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "moduleResolution": "bundler", "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]}The key entries:
"lib": ["ES2022", "DOM", "DOM.Iterable"]— required forCustomElementRegistry,ElementInternals, and other browser APIs used by HELiX"strict": true— required; HELiX types are authored under strict mode
JSX type declarations
Section titled “JSX type declarations”Create src/helix.d.ts so TypeScript recognizes HELiX elements in JSX:
import type { HelixButton, HelixTextInput, HelixCard } from '@helixui/library';
declare global { namespace JSX { interface IntrinsicElements { 'hx-button': React.DetailedHTMLProps< React.HTMLAttributes<HelixButton>, HelixButton > & { variant?: 'primary' | 'secondary' | 'tertiary' | 'danger' | 'ghost' | 'outline'; 'hx-size'?: 'sm' | 'md' | 'lg'; disabled?: boolean; loading?: boolean; type?: 'button' | 'submit' | 'reset'; }; 'hx-text-input': React.DetailedHTMLProps< React.HTMLAttributes<HelixTextInput>, HelixTextInput > & { value?: string; placeholder?: string; label?: string; disabled?: boolean; required?: boolean; name?: string; type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'search' | 'number' | 'date'; error?: string; }; 'hx-card': React.DetailedHTMLProps< React.HTMLAttributes<HelixCard>, HelixCard > & { variant?: 'default' | 'featured' | 'compact'; elevation?: 'flat' | 'raised' | 'floating'; }; // Add additional components as needed } }}The attribute name is
hx-size(notsize) onhx-button— HELiX prefixes the size attribute to avoid clashing with the native HTMLsizeattribute that Next.js / React may forward to other elements.
Using @helixui/react wrappers for full typing (recommended)
Section titled “Using @helixui/react wrappers for full typing (recommended)”@helixui/react provides auto-generated React wrapper components for every HELiX component. Each wrapper exposes typed onHx* callback props and eliminates ref-based event listeners:
npm install @helixui/react'use client';
import { HxButton } from '@helixui/react';
export function ActionBar() { return ( <HxButton variant="primary" onHxClick={() => console.log('clicked')}> Confirm </HxButton> );}See the React Wrappers guide for the full API including typed event callbacks, ref forwarding, slots, and the complete component list.
Form Handling with React Server Actions
Section titled “Form Handling with React Server Actions”HELiX form components use the ElementInternals API for native form participation — they work with standard HTML forms and therefore with React Server Actions.
Basic Server Action form
Section titled “Basic Server Action form”Server Components must not import HELiX modules directly — @customElement registration is browser-only. Split the page into a Server Component (the route + action wiring) and a Client Component (the rendered form):
// app/contact/page.tsx — Server Componentimport { submitContact } from './actions';import { ContactForm } from './contact-form';
export default function ContactPage() { return <ContactForm action={submitContact} />;}// app/contact/contact-form.tsx — Client Component'use client';
import '@helixui/library/components/hx-text-input';import '@helixui/library/components/hx-button';
export function ContactForm({ action }: { action: (fd: FormData) => void }) { return ( <form action={action}> <hx-text-input name="name" label="Full name" required /> <hx-text-input name="email" type="email" label="Email" required /> <hx-button type="submit" variant="primary">Send</hx-button> </form> );}'use server';
export async function submitContact(formData: FormData) { const name = formData.get('name') as string; const email = formData.get('email') as string; // process...}Because HELiX form components participate natively in the form, FormData receives their values automatically — no manual wiring needed.
Optimistic UI with useActionState
Section titled “Optimistic UI with useActionState”For client-side feedback during submission, combine a Server Action with useActionState:
'use client';
import { useActionState } from 'react';import { submitContact } from '@/app/contact/actions';import '@helixui/library/components/hx-text-input';import '@helixui/library/components/hx-button';
interface FormState { error?: string; success?: boolean;}
const initialState: FormState = {};
export function ContactForm() { const [state, formAction, isPending] = useActionState(submitContact, initialState);
return ( <form action={formAction}> {state.error && <p role="alert">{state.error}</p>} {state.success && <p role="status">Message sent!</p>}
<hx-text-input name="name" label="Full name" required /> <hx-text-input name="email" type="email" label="Email" required /> <hx-button type="submit" variant="primary" loading={isPending ? true : undefined}> {isPending ? 'Sending…' : 'Send'} </hx-button> </form> );}'use server';
interface FormState { error?: string; success?: boolean;}
export async function submitContact( _prevState: FormState, formData: FormData): Promise<FormState> { try { const name = formData.get('name') as string; const email = formData.get('email') as string; if (!name || !email) return { error: 'All fields are required.' }; // submit... return { success: true }; } catch { return { error: 'Submission failed. Please try again.' }; }}Avoiding Hydration Mismatch Warnings
Section titled “Avoiding Hydration Mismatch Warnings”Lit components register in the browser via customElements.define. On the server, Next.js renders custom element tags as unknown HTML elements. This is correct behavior — no hydration mismatch occurs for the element tag itself.
What DOES cause mismatches
Section titled “What DOES cause mismatches”-
Attributes that differ between server and client render
// Bad — Date.now() produces a different value server vs client<hx-card data-timestamp={Date.now()}>...</hx-card>// Good — derive stable values server-side<hx-card data-id={item.id}>...</hx-card> -
Conditional rendering based on browser APIs
// Bad — window is undefined on the server<hx-button disabled={typeof window === 'undefined'}>...</hx-button>// Good — use useEffect to apply browser-dependent state after hydration'use client';const [mounted, setMounted] = useState(false);useEffect(() => setMounted(true), []);return <hx-button disabled={!mounted ? true : undefined}>...</hx-button>; -
Dynamic class names generated client-side
Ensure any className applied to a wrapper element around HELiX components is deterministic on both server and client.
Suppressing false-positive warnings
Section titled “Suppressing false-positive warnings”If you use a third-party component that wraps HELiX elements and produces unavoidable mismatch warnings, suppress only the specific element:
'use client';
export function ClientOnlyWrapper({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) return null; return <>{children}</>;}Use sparingly — prefer fixing the root cause.
Performance: Lazy-Loading per Route
Section titled “Performance: Lazy-Loading per Route”Per-route component loading
Section titled “Per-route component loading”Only load the HELiX components a route actually needs. Use per-component imports instead of the full library barrel:
import dynamic from 'next/dynamic';
// Only loads hx-text-input and hx-button bundles for this routeconst FormPage = dynamic(() => import('@/components/contact-form'), { ssr: false, loading: () => <div>Loading…</div>,});
export default function FormsRoute() { return <FormPage />;}Inside contact-form.tsx, import only what you need:
'use client';
// Tree-shakeable per-component imports — import only what the route uses.import '@helixui/library/components/hx-text-input';import '@helixui/library/components/hx-button';Avoid importing the full library on every page:
// Avoid unless you use most components on every route.import '@helixui/library';Measure current component and bundle sizes locally before optimising for a budget — run node scripts/measure-component-size.js (in the HELiX monorepo) or pnpm run check:bundle to inspect per-entry min+gz output rather than relying on a doc-time approximation.
Bundling with Next.js transpilePackages
Section titled “Bundling with Next.js transpilePackages”If you encounter bundling issues with @helixui/library (ESM interop warnings), add it to transpilePackages in next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { transpilePackages: ['@helixui/library'],};
export default nextConfig;Preloading critical components
Section titled “Preloading critical components”For above-the-fold HELiX components, preload the script in your layout:
import { HelixLoader } from '@/components/helix-loader';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <head> {/* Preload the HELiX bundle for above-the-fold components */} <link rel="modulepreload" href="/_next/static/chunks/helix-library.js" /> </head> <body> <HelixLoader /> {children} </body> </html> );}Common Pitfalls and Solutions
Section titled “Common Pitfalls and Solutions”Pitfall: ReferenceError: customElements is not defined
Section titled “Pitfall: ReferenceError: customElements is not defined”Cause: A module importing @helixui/library executed on the server (outside a Client Component or useEffect).
Solution: Ensure the import is inside useEffect or mark the file as 'use client':
// Bad — runs during server renderimport '@helixui/library'; // top-level import in a Server Component
// Good — deferred to browser'use client';useEffect(() => { import('@helixui/library'); }, []);Pitfall: Event handlers silently not firing
Section titled “Pitfall: Event handlers silently not firing”Cause: HELiX uses custom events (hx-click, hx-change) that React’s synthetic event system does not forward.
Solution: Use a ref and addEventListener, or @lit/react wrappers:
'use client';
const ref = useRef<HTMLElement>(null);useEffect(() => { const el = ref.current; if (!el) return; const handler = (e: Event) => { /* ... */ }; el.addEventListener('hx-click', handler); return () => el.removeEventListener('hx-click', handler);}, []);
return <hx-button ref={ref}>Click me</hx-button>;Pitfall: Form values missing from FormData
Section titled “Pitfall: Form values missing from FormData”Cause: HELiX form components require name attribute to participate in form data.
Solution: Always provide the name attribute:
// Bad — value not included in FormData<hx-text-input label="Email" type="email" />
// Good<hx-text-input name="email" label="Email" type="email" required />Pitfall: disabled="false" still disables the component
Section titled “Pitfall: disabled="false" still disables the component”Cause: HTML boolean attributes treat any string value (including "false") as truthy. React does NOT omit the attribute when you pass disabled="false" as a string.
Solution: Use the boolean prop correctly — React omits it when false:
// Bad — disabled="false" is still truthy in HTML<hx-button disabled="false">Submit</hx-button>
// Good — React omits the attribute when the value is false<hx-button disabled={isDisabled}>Submit</hx-button>Pitfall: TypeScript error on HELiX element props
Section titled “Pitfall: TypeScript error on HELiX element props”Cause: JSX doesn’t know about custom element props without type declarations.
Solution: Add declarations to src/helix.d.ts as shown in the TypeScript section.
Pitfall: next/dynamic import resolves to undefined
Section titled “Pitfall: next/dynamic import resolves to undefined”Cause: The dynamic import path doesn’t match an exported member.
Solution: Use the .then((m) => m.ComponentName) pattern to select the named export:
// Bad — no named export selectedconst MyForm = dynamic(() => import('@/components/helix-form'));
// Goodconst MyForm = dynamic( () => import('@/components/helix-form').then((m) => m.HelixForm));Next Steps
Section titled “Next Steps”- React Integration — general React 18+ patterns
- Design Tokens — theming HELiX in your Next.js app
- Storybook — browse available components
- Self-certification scope — WCAG 2.2 AAA on P0 surface, AA baseline elsewhere
- Consumer obligations — what callers must wire when embedding HELiX in a Next.js app