
Migrating this site to EmDash
This site has changed CMS more times than I can count since 2003. This time I moved to EmDash on Cloudflare, and the reason is that most of my building now happens alongside an AI agent.
Read moreCredit to Kent C. Dodds for this idea, ghost routes are a handy way to create a reusable component route used by other routes, it takes Full-stack Components in a slightly different direction.
As Kent said in the Remix Discord:
I just discovered a new pattern I'm calling "Ghost Routes" which are files in the routes directory which are ignored by remix (via remix config) but still export loaders, actions, etc which can then be used by actual route files that are siblings to that route. This is like a halfway point between full stack components and routes. It allows me to make a component that's shared, but doesn't need its own route.
To start, ghost routes are prefixed by __, and inside remix.config.mjs, we tell it to ignore routes that start with __:
import { flatRoutes } from 'remix-flat-routes'
/**
* @type {import('@remix-run/dev').AppConfig}
*/
export default {
ignoredRouteFiles: ['**/*'],
serverModuleFormat: 'cjs',
tailwind: true,
postcss: true,
future: {
v2_headers: true,
v2_meta: true,
v2_errorBoundary: true,
v2_normalizeFormMethod: true,
v2_routeConvention: true,
v2_dev: true,
},
routes: async defineRoutes => {
return flatRoutes('routes', defineRoutes, {
ignoredRouteFiles: [
'.*',
'**/*.css',
'**/*.test.{js,jsx,ts,tsx}',
'**/__*.*', // <- ignore any route that begins with __
],
})
},
}Essentially, we've told Remix that having a file prefixed with __ means ignore it as a route, but it also allows us to place it next to the two routes that share the code. But it's more than just shared code.
To illustrate this, inside a folder in routes, for example app/routes/notes+, we can create a file called __note-editor.tsx
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import { redirect, json, type DataFunctionArgs } from '@vercel/remix'
import { useFetcher } from '@remix-run/react'
import { z } from 'zod'
import { Button } from '~/components/core/ui/button'
import { StatusButton } from '~/components/core/ui/status-button'
import { Icon } from '~/components/icons'
import * as React from "react";
import { isAuthenticated, getDirectusClient, createOne, updateOne } from "~/auth.server";
import { ErrorList, Field } from '~/components/core/forms'
import {
invariant,
useIsSubmitting,
} from '~/utils'
import { Editor } from "~/components/core/editor";
export const NoteEditorSchema = z.object({
id: z.string().optional(),
title: z.string().min(1),
body: z.string().min(1),
})
export async function action({ request }: DataFunctionArgs) {
const userAuthenticated = await isAuthenticated(request, true);
if (!userAuthenticated) {
return redirect("/signin");
}
const {token} = userAuthenticated;
await getDirectusClient({ token })
const formData = await request.formData()
const submission = parse(formData, {
schema: NoteEditorSchema,
acceptMultipleErrors: () => true,
})
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
let note: { id: string }
const { title, body, id } = submission.value
if (id) {
await updateOne('notes', id, {
title,
body
});
return redirect(`/notes/${id}`);
} else {
const newNote = await createOne('notes', {
title,
body
});
return redirect(`/notes/${newNote.id}`);
}
}
export function NoteEditor({
note,
}: {
note?: { id: string; title: string; body: string }
}) {
const noteEditorFetcher = useFetcher<typeof action>()
let [hidePreview, setHidePreview] = React.useState(false)
const [markup, setMarkup] = React.useState(note?.body || "" );
const handleChange = (newValue: string) => {
setMarkup(newValue)
}
const isSubmitting = useIsSubmitting()
const [form, fields] = useForm({
id: 'note-editor',
constraint: getFieldsetConstraint(NoteEditorSchema),
lastSubmission: noteEditorFetcher.data?.submission,
onValidate({ formData }) {
return parse(formData, { schema: NoteEditorSchema })
},
defaultValue: {
title: note?.title,
body: note?.body,
},
shouldRevalidate: 'onBlur',
})
return (
<noteEditorFetcher.Form
method="post"
className="flex h-full flex-col gap-y-4 overflow-x-hidden px-10 pb-28 pt-12"
{...form.props}
>
<input name="id" type="hidden" value={note?.id} />
<input type="hidden" value={markup} id="body" name="body" />
<Field
labelProps={{ children: 'Title' }}
inputProps={{
...conform.input(fields.title),
autoFocus: true,
}}
errors={fields.title.errors}
className="flex flex-col gap-y-2"
/>
<div>
<Editor
content={markup}
onChange={handleChange}
hidePreview={hidePreview}
/>
<div className="flex-1 mt-2 rounded-md border border-neutral-300 bg-gray-100 p-4">
{hidePreview ? <button
type="button"
name="showModel"
onClick={()=>setHidePreview(false)}
className="ml-5 rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
>
<Icon name="eye" />
Show Preview
</button> :
<button
type="button"
name="showModel"
onClick={()=>setHidePreview(true)}
className="ml-5 rounded bg-green-500 py-2 px-4 text-white hover:bg-green-600 focus:bg-green-400 disabled:bg-green-300"
>
<Icon name="eye-off" />
Hide Preview
</button>}
</div>
</div>
<ErrorList errors={form.errors} id={form.errorId} />
<div className="pt-5">
<div className="flex justify-end">
<Button
variant="destructive"
type="reset"
className="min-[525px]:max-md:aspect-square min-[525px]:max-md:px-0"
>
<Icon name="x-circle" className="scale-125 max-md:scale-150">
<span className="max-md:hidden">Reset</span>
</Icon>
</Button>
<StatusButton
status={
noteEditorFetcher.state === 'submitting'
? 'pending'
: noteEditorFetcher.data?.status ?? 'idle'
}
type="submit"
disabled={noteEditorFetcher.state !== 'idle'}
className="min-[525px]:max-md:aspect-square min-[525px]:max-md:px-0 text-white"
>
<Icon name="arrow-right" className="scale-125 max-md:scale-150">
<span className="max-md:hidden">Submit</span>
</Icon>
</StatusButton>
</div>
</div>
</noteEditorFetcher.Form>
)
}Then inside the same folder, we can create a file called $noteId_.edit.tsx:
import type {
ActionArgs,
LoaderArgs,
} from "@vercel/remix";
import { json, redirect } from "@vercel/remix";
import {
Form,
useLoaderData,
useActionData
} from "@remix-run/react";
import { CacheControl } from "~/utils/cache-control.server";
import { isAuthenticated, getDirectusClient, readOne, updateOne } from "~/auth.server";
import * as React from "react";
import {
invariant,
useIsSubmitting,
} from '~/utils'
import { NoteEditor, action } from './__note-editor'
// export the action directly from the note-editor
export { action }
export async function loader({ request, params }: LoaderArgs) {
invariant(params.noteId, "noteId not found");
const userAuthenticated = await isAuthenticated(request, true);
if (!userAuthenticated) {
return redirect("/signin");
}
const {token} = userAuthenticated;
await getDirectusClient({ token })
const note = await readOne("notes", params.noteId);
if (!note) {
throw new Response("Not Found", { status: 404 });
}
return json({
note
}, {
headers: {
"Cache-Control": new CacheControl("swr").toString()
},
});
}
export default function NoteDetailsPage() {
const { note } = useLoaderData<typeof loader>();
return (
<NoteEditor note={note} />
);
}Finally, we create a file called new.tsx:
import type { ActionArgs } from "@vercel/remix";
import { json, redirect } from "@vercel/remix";
import { Form, useActionData } from "@remix-run/react";
import * as React from "react";
import { NoteEditor, action } from './__note-editor'
// export the action directly from the note-editor
export { action }
export default function NewNotePage() {
return (
<NoteEditor />
);
}Both of these files use the __note-editor ghost route as an editor as well as to handle the form submission, this lets us keep code clean.
---
I've updated my Directed Stack repo to use this method and it works great. Previously I'd have stored the notes-editor route inside resources which also worked fine but this way the routes and related files stay together.

This site has changed CMS more times than I can count since 2003. This time I moved to EmDash on Cloudflare, and the reason is that most of my building now happens alongside an AI agent.
Read more
I wanted a simple dashboard that showed me events as they happened, so I wrote a quick little Astro app. It grew into Get Butters: a real-time event feed with a REST API, a CLI, insight cards and push alerts.
Read more
Once four agents can edit the same repo, architecture stops being the hard problem and coordination starts. The five roles I run and what each one is forbidden to touch, why every handoff goes through a file, a worktree script so agents stop racing, and the failure modes worth naming.
Read more