A minimal app
A minimal app in the Dusk guide.
vite.config.ts
src/
app.tsx root component: wraps every page
entry-server.ts options of the request handler (optional)
routes/
_layout.tsx layout of every page
index.tsx /
posts/[id].tsx /posts/:id (loader calls a server function)
api/posts.ts /api/posts (API route)
server/
posts.ts server functions
Everything below is the content of those files. The configuration is one plugin:
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [dusk()] });
src/app.tsx is the root component. It receives the routed page as children, on the server and in the browser:
import type { JSX } from "@shadow-js/web";
export default function App(props: { children?: JSX.Element }): JSX.Element {
return <div class="shell">{props.children}</div>;
}
src/entry-server.ts holds the options of createHandler. The generated server entry reads its options export or
its default export. allowedHosts is the one option you should always set in production; without it the first
production request is served after one warning.
import type { HandlerOptions } from "@shadow-js/dusk/server";
const options: Omit<HandlerOptions, "manifest"> = {
security: { allowedHosts: ["localhost", "example.com"] },
};
export default options;
The data lives behind server functions. A server function is async code that runs only on the server; the
browser build replaces it with a stub that sends the call over HTTP. src/schema.ts is a stand-in for a schema
library: any Standard Schema (Zod, Valibot, ArkType) works in its place.
import type { StandardSchemaV1 } from "@shadow-js/dusk/server";
/** Wraps a parse function that throws on bad input as a Standard Schema. */
export function schema<T>(parse: (value: unknown) => T): StandardSchemaV1<T, T> {
return {
"~standard": {
version: 1,
vendor: "app",
validate(value) {
try {
return { value: parse(value) };
} catch (error) {
return { issues: [{ message: error instanceof Error ? error.message : "invalid" }] };
}
},
},
};
}
import { HttpError, serverFn } from "@shadow-js/dusk/server";
import { schema } from "../schema";
export interface Post {
id: string;
title: string;
}
const posts = new Map<string, Post>([
["1", { id: "1", title: "First post" }],
["2", { id: "2", title: "Second post" }],
]);
const postId = schema((value) => {
if (typeof value === "object" && value !== null && "id" in value && typeof value.id === "string") {
return { id: value.id };
}
throw new Error("id must be a string");
});
export const getPost = serverFn({ method: "GET", input: postId }, async ({ id }): Promise<Post> => {
const post = posts.get(id);
if (post === undefined) throw new HttpError(404, "No such post");
return post;
});
export function listPosts(): Post[] {
return [...posts.values()];
}
Routes are files. The layout wraps every page and renders the matched child with Outlet; A is a link that
navigates in the browser.
import { A, Outlet } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
export default function Layout(): JSX.Element {
return (
<main>
<nav>
<A to="/">Home</A> <A to="/posts/:id" params={{ id: "1" }}>Post 1</A>
</nav>
<Outlet />
</main>
);
}
import type { HeadTag, JSX } from "@shadow-js/web";
export const head = (): HeadTag[] => [{ tag: "title", children: "Home" }];
export default function Home(): JSX.Element {
return <p>Welcome</p>;
}
The post page has a loader. It runs on the server for the first request and in the browser for later
navigations, so it may only call code that is safe in both places: a server function is.
import { type LoaderArgs, useLoaderData } from "@shadow-js/router";
import type { HeadTag, JSX } from "@shadow-js/web";
import { getPost, type Post } from "../../server/posts";
export const loader = ({ params }: LoaderArgs<"/posts/:id", unknown>): Promise<Post> =>
getPost({ id: params.id });
export const head = ({ loaderData }: { loaderData: Post | undefined }): HeadTag[] => [
{ tag: "title", children: loaderData?.title ?? "Post" },
];
export default function PostPage(): JSX.Element {
const post = useLoaderData<Post>();
return (
<article>
<h1>{post()?.title}</h1>
</article>
);
}
A file with HTTP-method exports and no default export is an API route. The handler answers it before the page router sees the path.
import { json } from "@shadow-js/dusk/server";
import { listPosts } from "../../server/posts";
export const GET = () => json({ posts: listPosts() });