├── .gitignore ├── README.md ├── bin └── newpage.js ├── bsconfig.json ├── package-lock.json ├── package.json └── src ├── Next.re └── NextServer.re /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .merlin 3 | .bsb.lock 4 | npm-debug.log 5 | lib 6 | node_modules 7 | *.bs.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Next.js, but in ReasonML and Bucklescript! 2 | 3 | Type definitions for Next.js. 4 | 5 | State: 6 | 7 | - Mostly complete type definitions. Feel free to open a pull-request to add more! 8 | - Wraps API for Next version 9.x.x on NPM 9 | - Only works with Bucklescript 7.x and above. 10 | 11 | # The Basics. 12 | 13 | 0. Have a reason project already set up 14 | 15 | 1. Install: 16 | 17 | ``` 18 | npm install --save next@9 reason-nextjs 19 | ``` 20 | 21 | (Notice how you installed both next and nextdotbs!) 22 | 23 | 2. Add "reason-nextjs" to your "bs-dependencies" in your "bsconfig.json" 24 | 25 | 3. [Install ReasonReact](https://reasonml.github.io/reason-react/docs/en/installation) 26 | 27 | 4. `mkdir pages` 28 | 29 | 5. Make a file: `src/pages/WelcomePage.re` **that defines a React component named default**. 30 | 31 | ``` 32 | open React; 33 | open Next; 34 | 35 | [@react.component] 36 | let default = () => { 37 |
38 | 39 | "Welcome"->string 40 | 41 | "Welcome to Next"->string 42 |
; 43 | }; 44 | ``` 45 | 46 | 6. Symlink that file into the pages directory: 47 | 48 | `cd pages && ln -s ../src/pages/WelcomePage.bs.js welcome.js` 49 | 50 | The symlinking allows us to still use the .bs.js postfix for Reason files, and also allows us to use dynamic routing and name files with square brackets inside of the `pages` directory without upsetting `bsb`. 51 | 52 | 7. Launch Next and open the browser, etc. as explained in [the Next.js docs](https://nextjs.org/docs) 53 | 54 | ## Data Fetching 55 | 56 | Next performs data fetching for page components by looking for a static function on the component class called `getInitialProps`. This isn't a pattern that ReasonReact likes, so instead, these wrappings expose a function called `assignPropsFetcher` that you pass your `default` value (from creating the React component) to: 57 | 58 | ```reason 59 | open React; 60 | open Next; 61 | 62 | [@react.component] 63 | let default = (~message: string) => { 64 |
65 | 66 | "Welcome"->string 67 | 68 | "Welcome to Next"->string 69 |
; 70 | }; 71 | 72 | // Notice how I match the props for the component above! 73 | type props = {message: option(Post.t)}; 74 | 75 | let fetcher: propsFetcher(props) = 76 | ({req, query}) => { 77 | // ... do some async stuff we don't show here and get a value called "message" in scope. 🧙‍♂️ 78 | {message: message}->Js.Promise.resolve; 79 | }; 80 | 81 | default->assignPropsFetcher(fetcher); 82 | ``` 83 | 84 | That's most of it! So to re-cap, you can use it just like Next.js except: 85 | 86 | - Symlink your compiled Reason artifacts into the correct location in the `pages` folder. 87 | - Use `assignPropsFetcher` instead of `getInitialProps`. 88 | - The `req` param that gets passed into the props fetcher will be `None` on the client, but will be an object with a `headers` field on the server. If you're making a request from your own server to your own API to populate data, you can get the `host` and use it as the host for your HTTP request. 89 | 90 | Other things to note: 91 | 92 | - A `Head` React component is available for inserting tags into the head of the document. 93 | - A `Link` React component is availalbe for slick in-app navigation. 94 | 95 | ## Usage with Serbet. 96 | 97 | You may want to run Next alongside your own API, and you may be using [Serbet](https://github.com/mrmurphy/serbet) for that. Here's how they can work together: 98 | 99 | ```reason 100 | open Async; 101 | 102 | let nextServer = 103 | NextServer.make({ 104 | dev: 105 | ! 106 | Node.Process.process##env 107 | ->Js.Dict.get("NODE_ENV") 108 | ->Belt.Option.map(a => a == "production") 109 | ->Belt.Option.getWithDefault(false), 110 | }); 111 | 112 | { 113 | let%Async _ = nextServer->NextJSServer.prepare; 114 | let app = 115 | Serbet.application([ 116 | // your API routes here 117 | ]); 118 | app.router->Express.Router.use(nextServer->NextServer.getRequestHandler); 119 | async(); 120 | }; 121 | ``` 122 | -------------------------------------------------------------------------------- /bin/newpage.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const mkdirp = require("mkdirp"); 5 | const path = require("path"); 6 | 7 | const pagesDirExists = fs.existsSync("pages"); 8 | 9 | let pagePath = process.argv[2]; 10 | if (!pagePath) { 11 | console.log( 12 | "Please give me the path to a page you want to create! like: posts/[pid]" 13 | ); 14 | process.exit(1); 15 | } 16 | 17 | function capitalize(str) { 18 | return str.charAt(0).toUpperCase() + str.substring(1); 19 | } 20 | 21 | function makeBSBSafe(path) { 22 | let noSquareBrackets = path 23 | .replace(/\[/g, "Dynamic_") 24 | .replace(/\]/g, "") 25 | .replace(/\.\.\./g, "All_"); 26 | let splitted = noSquareBrackets.split("/"); 27 | let filename = splitted[splitted.length - 1]; 28 | splitted[splitted.length - 1] = capitalize(filename); 29 | return splitted.join("/"); 30 | } 31 | 32 | let srcPath = makeBSBSafe(`src/pages/${pagePath}`); 33 | let linkPath = `pages/${pagePath}`; 34 | 35 | if (!fs.existsSync(srcPath)) { 36 | let splitted = srcPath.split("/"); 37 | mkdirp.sync(splitted.slice(0, splitted.length - 1).join("/")); 38 | } 39 | 40 | if (!fs.existsSync(linkPath)) { 41 | let splitted = linkPath.split("/"); 42 | mkdirp.sync(splitted.slice(0, splitted.length - 1).join("/")); 43 | } 44 | 45 | let finalSrcPath = srcPath + ".bs.js"; 46 | let finalLinkPath = linkPath + ".js"; 47 | 48 | if (!fs.existsSync(srcPath + ".re")) { 49 | // Write the BS file in place 50 | fs.writeFileSync( 51 | srcPath + ".re", 52 | ` 53 | open React; 54 | open Next; 55 | 56 | [@react.component] 57 | let default = () => { 58 |
"Hello"->string
; 59 | }; 60 | ` 61 | ); 62 | } 63 | 64 | let moduleName = path.basename(finalSrcPath, ".bs.js"); 65 | 66 | if (!fs.existsSync(finalLinkPath)) { 67 | fs.writeFileSync( 68 | finalLinkPath, 69 | ` 70 | import ${moduleName} from "${path.relative( 71 | path.dirname(finalLinkPath), 72 | finalSrcPath 73 | )}"; 74 | export default ${moduleName}; 75 | ` 76 | ); 77 | } 78 | 79 | console.log(`Done! ${finalLinkPath} should now point to ${finalSrcPath}`); 80 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextdotbs", 3 | "reason": { "react-jsx": 3 }, 4 | "sources": [ 5 | { 6 | "dir": "src", 7 | "subdirs": true 8 | } 9 | ], 10 | "package-specs": { 11 | "module": "commonjs", 12 | "in-source": true 13 | }, 14 | "suffix": ".bs.js", 15 | "bs-dependencies": ["reason-react"], 16 | "ppx-flags": [] 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reason-nextjs", 3 | "version": "0.2.3", 4 | "scripts": { 5 | "clean": "bsb -clean-world", 6 | "build": "bsb -make-world", 7 | "start": "bsb -make-world -w" 8 | }, 9 | "bin": { 10 | "newpage": "./bin/newpage.js" 11 | }, 12 | "repository": "https://github.com/mrmurphy/reason-nextjs", 13 | "keywords": [ 14 | "Reason", 15 | "NextJS", 16 | "Express", 17 | "HTTP" 18 | ], 19 | "author": "Murphy Randle", 20 | "license": "MIT", 21 | "peerDependencies": { 22 | "bs-platform": "^7.x.x", 23 | "reason-react": "^0.7.0" 24 | }, 25 | "dependencies": { 26 | "mkdirp": "^1.0.3", 27 | "next": "^9.1.6" 28 | }, 29 | "devDependencies": { 30 | "bs-platform": "^7.0.1", 31 | "reason-react": "^0.7.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Next.re: -------------------------------------------------------------------------------- 1 | // These are standard Node.js Request and Response objects, but we wanted to 2 | // allow other libraries to wrap those APIs, and didn't want to bloat the size 3 | // of this library by including one. If you want to do more with the request 4 | // than get the headers, or if you want to do something special with the 5 | // response, we suggest tasteful use of Obj.magic to convert these values to the 6 | // type of choice that fits with the Node.js library you are using. 7 | type request = {headers: Js.Dict.t(string)}; 8 | type response; 9 | 10 | type context = { 11 | // Current route. That is the path of the page in /pages 12 | pathname: string, 13 | // Query string section of URL parsed as an object 14 | query: Js.Dict.t(string), 15 | // String of the actual path (including the query) shown in the browser 16 | asPath: string, 17 | // HTTP request object (server only) 18 | req: request, 19 | // HTTP response object (server only) 20 | res: response, 21 | // Error object if any error is encountered during the rendering 22 | err: Js.Exn.t, 23 | }; 24 | 25 | [@bs.set] 26 | external assignGetInitialProps: 27 | (React.component('props), context => Js.Promise.t('props)) => unit = 28 | "getInitialProps"; 29 | 30 | // 31 | // React Components 32 | // 33 | module Head = { 34 | [@react.component] [@bs.module "next/head"] 35 | external make: (~children: React.element) => React.element = "default"; 36 | }; 37 | 38 | module Link = { 39 | [@react.component] [@bs.module "next/link"] 40 | external make: 41 | ( 42 | ~children: React.element, 43 | ~href: string, 44 | ~as_: string=?, 45 | ~passHref: bool=?, 46 | ~prefetch: bool=?, 47 | ~shallow: bool=?, 48 | ~replace: bool=?, 49 | ~scroll: bool=? 50 | ) => 51 | React.element = 52 | "default"; 53 | }; 54 | 55 | // 56 | // Hooks 57 | // 58 | 59 | module Router = { 60 | type t = { 61 | // Current route. That is the path of the page in /pages 62 | pathname: string, 63 | // The query string parsed to an object. Defaults to {} 64 | query: Js.Dict.t(string), 65 | // Actual path (including the query) shown in the browser 66 | asPath: string, 67 | }; 68 | 69 | type pushOptions = { 70 | shallow: bool, 71 | getInitialProps: option(bool), 72 | }; 73 | [@bs.send] 74 | external push: 75 | (t, ~url: string, ~asUrl: string=?, ~options: pushOptions=?, unit) => unit = 76 | "push"; 77 | 78 | [@bs.send] 79 | external replace: 80 | (t, ~url: string, ~asUrl: string=?, ~options: pushOptions=?, unit) => unit = 81 | "replace"; 82 | 83 | type popStateContext = { 84 | url: string, 85 | as_: string, 86 | options: pushOptions, 87 | }; 88 | [@bs.send] 89 | external beforePopState: (t, popStateContext => bool) => unit = 90 | "beforePopState"; 91 | 92 | (); 93 | // Events are not wrapped at the moment. Feel free to contribute! 94 | }; 95 | 96 | [@bs.module "next/router"] external useRouter: unit => Router.t = "useRouter"; -------------------------------------------------------------------------------- /src/NextServer.re: -------------------------------------------------------------------------------- 1 | // These are the pieces you need if you want to add Next.js to an existing Express server. 2 | 3 | type server; 4 | type opts = {dev: bool}; 5 | 6 | // First, make a Next app. 7 | [@bs.module] external make: opts => server = "next"; 8 | 9 | // Then prepare it, and after the preparation is done, you can start your express app. 10 | [@bs.send] external prepare: server => Js.Promise.t(unit) = "prepare"; 11 | 12 | // This will return a middleware that you can pass to Express's "App.use" or "Router.use". 13 | [@bs.send] 14 | external getRequestHandler: server => 'expressMiddleware = "getRequestHandler"; --------------------------------------------------------------------------------