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 children is allowed, and reported. The gate renders without its page on purpose. In ecopages build that would fail, as Layout <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 render runs for a signed-out visitor too, so load private data in the middleware after the session check, not in render. The redirect pattern never reaches render without a session.
  • The gate answers 200. If a signed-out visitor should get 401, or a crawler should not index the sign-in screen, return that response from the middleware instead.
  • requires is a type, not a check. requires: ['session'] narrows locals.session in render and does not verify it. The middleware must enforce it, as requireSession does.
  • 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, Secure and SameSite attributes and a value your server can verify.