Ghost routes in remix

Roger Stringer Roger Stringer
August 16, 2023
8 min read

Credit 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.

Note

I'm using Flat Routes in the Directed Stack so your route organization may be different.

FYI, since this example comes directly from the Directed Stack repo, I'm not sharing every piece of code involved, only focusing on how to structure your ghost route, you can look at the repo to see how to do everything else.

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.

Do you like my content?

Sponsor Me On Github