Auth-gated layout
Use this when the same URL must render differently for signed-in and signed-out visitors, or send signed-out visitors elsewhere. It uses page middleware, request locals and a layout, with no auth library; swap in yours for the session check.
Two patterns, both in the fixture:
- Gate in the layout. The page's middleware stores the session in
locals, and the layout renders the page or a sign-in screen. Use it when the URL stays the same. - Redirect in the middleware. The middleware returns a redirect before the page renders. Use it when signed-out visitors belong on another URL.
Read the session
The middleware turns the request into a locals.session value. The cookie check below is a stand-in: replace readSession with your library's call, such as validating a signed token or looking the session up in your store.
import type { FileRouteMiddleware } from '@ecopages/core';
export const SESSION_COOKIE = 'session';
/**
* Reads the signed-in user from the request cookie.
*
* @remarks
* A stand-in for your auth library: any non-empty cookie counts as a session. Replace the body with a real
* check, such as validating a signed token or looking the session up in your store.
*/
export function readSession(request: Request): { user: string } | null {
const cookie = request.headers.get('cookie') ?? '';
const user = cookie
.split(';')
.map((part) => part.trim().split('='))
.find(([name]) => name === SESSION_COOKIE)?.[1];
return user ? { user: decodeURIComponent(user) } : null;
}
/** Puts the session on `locals` and always continues, so the layout decides what to show. */
export const loadSession: FileRouteMiddleware = (ctx, next) => {
ctx.locals.session = readSession(ctx.request);
return next();
};
/** Redirects a request without a session to the sign-in page. */
export const requireSession: FileRouteMiddleware = (ctx, next) => {
const session = readSession(ctx.request);
if (!session) {
return ctx.response.redirect('/sign-in');
}
ctx.locals.session = session;
return next();
};Declare the shape of locals.session once, so pages and layouts are typed:
declare module '@ecopages/core' {
interface RequestLocals {
session?: { user: string } | null;
}
}Gate in the layout
loadSession never blocks; it only fills locals. The layout decides what the visitor sees.
import type { LayoutProps } from '@ecopages/core';
import type { JsxRenderable } from '@ecopages/jsx';
/** Renders the page for a signed-in visitor and a sign-in form, without the page, for everyone else. */
export function AuthGate({ children, locals }: LayoutProps<JsxRenderable>) {
if (!locals?.session) {
return (
<main data-gate="sign-in">
<h1>Sign in to continue</h1>
<form method="post" action="/api/sign-in">
<button type="submit">Sign in</button>
</form>
</main>
);
}
return (
<main data-gate="page">
<p>Signed in as {locals.session.user}</p>
{children}
</main>
);
}import { eco } from '@ecopages/core';
import { AuthGate } from '../layouts/auth-gate';
import { loadSession } from '../lib/session.server';
export default eco.page({
layout: AuthGate,
cache: 'dynamic',
middleware: [loadSession],
metadata: () => ({ title: 'Dashboard', description: 'Visible to signed-in visitors.' }),
render: () => <h1>Dashboard</h1>,
});cache: 'dynamic' is required. Page middleware and request locals exist only for dynamic pages, and a layout that reads locals can wrap only dynamic pages: on any other page the access throws LocalsAccessError. See Request Locals.
Redirect in the middleware
requireSession returns a redirect when there is no session and otherwise continues.
import { eco } from '@ecopages/core';
import { requireSession } from '../lib/session.server';
export default eco.page({
cache: 'dynamic',
middleware: [requireSession],
requires: ['session'],
metadata: () => ({ title: 'Account', description: 'Redirects visitors who are not signed in.' }),
render: ({ locals }) => <h1>Account of {locals.session.user}</h1>,
});The sign-in form posts to an endpoint that sets the cookie:
import { definePost } from '@ecopages/core';
import { SESSION_COOKIE } from '../../lib/session.server';
export const POST = definePost({
handler: ({ response }) =>
response
.headers({ 'Set-Cookie': `${SESSION_COOKIE}=demo; Path=/; HttpOnly; SameSite=Lax` })
.redirect('/dashboard', 303),
});What the test checks
The fixture's end-to-end test runs against ecopages dev and ecopages start. A request without a cookie gets the sign-in screen and not the dashboard heading. A request with a session cookie gets the dashboard. /account answers 302 to /sign-in without a session, and the endpoint answers 303 with a Set-Cookie and a redirect to /dashboard. The dev run also asserts the layout's children warning below.
Caveats
- A layout that drops
childrenis allowed, and reported. The gate renders without its page on purpose. Inecopages buildthat would fail, asLayout <name> (<file>) did not render its children, but a dynamic page is not built. A development request logs the message as a warning, and a production request returns the HTML silently. See Layouts. - The page still renders. The layout only chooses whether to include its output. The page's
renderruns for a signed-out visitor too, so load private data in the middleware after the session check, not inrender. The redirect pattern never reachesrenderwithout a session. - The gate answers
200. If a signed-out visitor should get401, or a crawler should not index the sign-in screen, return that response from the middleware instead. requiresis a type, not a check.requires: ['session']narrowslocals.sessioninrenderand does not verify it. The middleware must enforce it, asrequireSessiondoes.- Do not cache the response. Dynamic pages are rendered per request. Keep the page
dynamic; a cached or static page would show one visitor's version to everyone. - The cookie is a demo. Use
HttpOnly,SecureandSameSiteattributes and a value your server can verify.