Skip to content

Instantly share code, notes, and snippets.

@mehmetnyarar
Last active January 22, 2021 14:14
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mehmetnyarar/800111eee5d9b1462012dff61f29c82f to your computer and use it in GitHub Desktop.
Save mehmetnyarar/800111eee5d9b1462012dff61f29c82f to your computer and use it in GitHub Desktop.
NextJS with Custom Routes (forked from [next-routes](https://github.com/fridays/next-routes))

NextJS with Custom Routes (next-routes.tsx)

<project_root>/src/routes/types.ts:

export interface IRoute {
  name: string
  page: string
  pattern: string
}

<project_root>/src/routes/next-routes.tsx:

/* eslint-disable node/no-deprecated-api */

import { Request, Response } from 'express'

import React from 'react'
import { Server } from 'next'
import NextLink from 'next/link'
import NextRouter from 'next/router'

import pathToRegexp, { Key, PathFunction } from 'path-to-regexp'
import { ParsedUrlQuery } from 'querystring'
import { parse, UrlWithParsedQuery } from 'url'

import { IRoute } from './types'

interface IRouteParams {
  [key: string]: string
  [key: number]: string
}

interface IMatch {
  query: ParsedUrlQuery
  parsedUrl: UrlWithParsedQuery
  pathname?: string
  params?: IRouteParams
  route?: Route
}

const toQueryString = (query: ParsedUrlQuery) => {
  return Object.keys(query)
    .filter(key => query[key] !== null && query[key] !== undefined)
    .map(key => {
      let value = query[key]

      if (Array.isArray(value)) {
        value = value.join('/')
      }

      return [
        encodeURIComponent(key),
        encodeURIComponent(value)
      ].join('=')
    }).join('&')
}

class Route {
  name: string;
  pattern: string;
  page: string;
  regex: RegExp;
  keys: Key[];
  keyNames: any;
  toPath: PathFunction<object>;

  constructor ({ name, pattern, page = name }: IRoute) {
    if (!name && !page) {
      throw new Error(`Missing page to render for route '${pattern}'`)
    }

    this.name = name
    this.pattern = pattern || `/${name}`
    this.page = page.replace(/(^|\/)index$/, '').replace(/^\/?/, '/')
    this.regex = pathToRegexp(this.pattern, this.keys = [])
    this.keyNames = this.keys.map(key => key.name)
    this.toPath = pathToRegexp.compile(this.pattern)
  }

  match (pathname?: string) {
    if (!pathname) return undefined

    const values = this.regex.exec(pathname)
    return values
      ? this.valuesToParams(values.slice(1))
      : undefined
  }

  valuesToParams (values: string[]) {
    return values.reduce((params, val, i) => {
      if (val === undefined) return params
      return Object.assign(params, {
        [this.keys[i].name]: decodeURIComponent(val)
      })
    }, {} as IRouteParams)
  }

  getHref (query: ParsedUrlQuery = {}) {
    return `${this.page}?${toQueryString(query)}`
  }

  getAs (query: ParsedUrlQuery = {}) {
    const as = this.toPath(query) || '/'
    const keys = Object.keys(query)
    const qsKeys = keys.filter(key => this.keyNames.indexOf(key) === -1)

    if (!qsKeys.length) return as

    const qsParams = qsKeys.reduce((qs, key) => Object.assign(qs, {
      [key]: query[key]
    }), {})

    return `${as}?${toQueryString(qsParams)}`
  }

  getUrls (query: ParsedUrlQuery) {
    return {
      as: this.getAs(query),
      href: this.getHref(query)
    }
  }
}

export class Routes {
  routes: Route[];
  Link: (props: any) => JSX.Element;
  Router: any;

  constructor ({
    Link = NextLink,
    Router = NextRouter
  } = {}) {
    this.routes = []
    this.Link = this.getLink(Link)
    this.Router = this.getRouter(Router)
  }

  add (name: string | IRoute, pattern?: string, page?: string) {
    let route: IRoute | undefined

    if (name instanceof Object) {
      route = Object.assign({}, name)
    } else if (typeof name === 'string') {
      if (!pattern) throw new Error(`'pattern' is required`)
      if (!page) throw new Error(`'page' is required`)
      route = {
        name,
        page,
        pattern
      }
    }

    if (!route) throw new Error(`'route' is required`)
    if (this.findByName(route.name)) {
      throw new Error(`Route '${route.name}' already exists`)
    }

    this.routes.push(new Route(route))
    return this
  }

  findByName (name: string) {
    return this.routes.find(route => route.name === name)
  }

  match (url: string) {
    const parsedUrl = parse(url, true)
    const { pathname, query } = parsedUrl

    return this.routes.reduce((result, route) => {
      if (result.route) return result

      const params = route.match(pathname)
      if (!params) return result

      return { ...result, route, params, query: { ...query, ...params } }
    }, { query, parsedUrl } as IMatch)
  }

  findAndGetUrls (nameOrUrl: string, query: ParsedUrlQuery) {
    const route = this.findByName(nameOrUrl)

    if (route) {
      return {
        route,
        urls: route.getUrls(query),
        byName: true
      }
    } else {
      const { route, query } = this.match(nameOrUrl)
      const href = route ? route.getHref(query) : nameOrUrl
      const urls = { href, as: nameOrUrl }
      return {
        route,
        urls
      }
    }
  }

  getRequestHandler (app: Server, customHandler?: any) {
    const nextHandler = app.getRequestHandler()

    return (req: Request, res: Response) => {
      const { route, query, parsedUrl } = this.match(req.url)

      if (route) {
        if (customHandler) {
          customHandler({ req, res, route, query })
        } else {
          app.render(req, res, route.page, query)
        }
      } else {
        nextHandler(req, res, parsedUrl)
      }
    }
  }

  getLink (Link: any) {
    const LinkRoutes = (props: any) => {
      const { route, params, to, ...newProps } = props
      const nameOrUrl = route || to

      if (nameOrUrl) {
        Object.assign(newProps, this.findAndGetUrls(nameOrUrl, params).urls)
      }

      return <Link {...newProps} />
    }

    return LinkRoutes
  }

  getRouter (Router: any) {
    const wrap = (method: any) => (route: any, params: any, options: any) => {
      const { byName, urls: { as, href } } = this.findAndGetUrls(route, params)
      return Router[method](href, as, byName ? options : params)
    }

    Router.pushRoute = wrap('push')
    Router.replaceRoute = wrap('replace')
    Router.prefetchRoute = wrap('prefetch')

    return Router
  }
}

<project_root>/src/routes/routes.ts:

import { Routes } from './next-routes'
import { IRoute } from './types'

const allRoutes: IRoute[] = [
  // your routes
]

const routes = new Routes()
allRoutes.forEach(route => routes.add(route))

export default routes
export const { Link, Router } = routes

<project_root>/server/index.ts:

import express from 'express'
import next from 'next'
import routes from '../src/routes/routes'

const port = parseInt(process.env.PORT || '3000', 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handler = routes.getRequestHandler(app)

app.prepare().then(async () => {
  const server = express()

  server.use(handler)

  server.listen(port, (err) => {
    if (err) throw err
    console.log(`> Ready on http://localhost:${port}`)
  })
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment